@kolkrabbi/kol-component 0.88.0 → 0.90.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.88.0",
3
+ "version": "0.90.0",
4
4
  "description": "KOL design-system components — atoms through organisms, emitting canonical kol-* classes. Pairs with @kolkrabbi/kol-theme for styling.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/index.js CHANGED
@@ -135,6 +135,7 @@ export { default as MediaLibrary, MediaLibraryProvider, useMediaLibrary, MediaPi
135
135
  export { default as MediaTileGallery } from './organisms/MediaTileGallery.jsx'
136
136
  export { default as MediaViewer } from './organisms/MediaViewer.jsx'
137
137
  export { default as SettingsPanel, SettingsRow, SettingsSwitch, SettingsChoice, SettingsChipRow, SettingsFooter } from './organisms/SettingsPanel.jsx'
138
+ export { default as SectionNewsletter } from './organisms/SectionNewsletter.jsx'
138
139
  export { default as NewsletterBand } from './organisms/NewsletterBand.jsx'
139
140
  export { default as RecordManager } from './organisms/RecordManager.jsx'
140
141
  export { default as SpectrumGrid } from './organisms/SpectrumGrid.jsx'
@@ -41,8 +41,13 @@ import usePrefersReducedMotion from '../hooks/usePrefersReducedMotion.js'
41
41
  *
42
42
  * @param {string} form 'grid' | 'list'
43
43
  * @param {string} min grid track minimum, card form (default 320px)
44
- * @param {number} cols OPT-IN column count, grid form: 1 below md, N from
45
- * md (2–6). Wins over `min`; the list form ignores it
44
+ * @param {number|object} cols OPT-IN column count, grid form. A number: 1 below
45
+ * md, N from md (1–6). A map per breakpoint
46
+ * (`{ md: 3, xl: 4 }` — sm · md · lg · xl · 2xl): 1
47
+ * below the first rung, each rung's count from there
48
+ * (ContentCollectionColsResponsive, 2026-08-27 —
49
+ * user: "can we make it 4 in the biggest breakpoint?").
50
+ * Wins over `min`; the list form ignores it
46
51
  * @param {string} listMin OPT-IN: makes the list multi-column too, for a dense
47
52
  * file-browser cut. Unset = one full-width column
48
53
  * @param {number} gap px between items. UNSET reads the token for the
@@ -53,13 +58,19 @@ import usePrefersReducedMotion from '../hooks/usePrefersReducedMotion.js'
53
58
  * nine call sites. Pass a number only to differ
54
59
  * @param {boolean} stagger enter animation on/off (reduced motion wins)
55
60
  */
56
- const COLS = {
57
- 1: 'grid-cols-1',
58
- 2: 'grid-cols-1 md:grid-cols-2',
59
- 3: 'grid-cols-1 md:grid-cols-3',
60
- 4: 'grid-cols-1 md:grid-cols-4',
61
- 5: 'grid-cols-1 md:grid-cols-5',
62
- 6: 'grid-cols-1 md:grid-cols-6',
61
+ /* literal per rung × count — a class built at runtime is never emitted */
62
+ const COLS_AT = {
63
+ sm: { 1: 'sm:grid-cols-1', 2: 'sm:grid-cols-2', 3: 'sm:grid-cols-3', 4: 'sm:grid-cols-4', 5: 'sm:grid-cols-5', 6: 'sm:grid-cols-6' },
64
+ md: { 1: 'md:grid-cols-1', 2: 'md:grid-cols-2', 3: 'md:grid-cols-3', 4: 'md:grid-cols-4', 5: 'md:grid-cols-5', 6: 'md:grid-cols-6' },
65
+ lg: { 1: 'lg:grid-cols-1', 2: 'lg:grid-cols-2', 3: 'lg:grid-cols-3', 4: 'lg:grid-cols-4', 5: 'lg:grid-cols-5', 6: 'lg:grid-cols-6' },
66
+ xl: { 1: 'xl:grid-cols-1', 2: 'xl:grid-cols-2', 3: 'xl:grid-cols-3', 4: 'xl:grid-cols-4', 5: 'xl:grid-cols-5', 6: 'xl:grid-cols-6' },
67
+ '2xl': { 1: '2xl:grid-cols-1', 2: '2xl:grid-cols-2', 3: '2xl:grid-cols-3', 4: '2xl:grid-cols-4', 5: '2xl:grid-cols-5', 6: '2xl:grid-cols-6' },
68
+ }
69
+ const colsClasses = (cols) => {
70
+ const map = typeof cols === 'number' ? { md: cols } : cols
71
+ if (!map || typeof map !== 'object') return ''
72
+ const rungs = Object.keys(COLS_AT).map((bp) => COLS_AT[bp][map[bp]]).filter(Boolean)
73
+ return rungs.length ? ['grid-cols-1', ...rungs].join(' ') : ''
63
74
  }
64
75
 
65
76
  export default function ContentCollection({
@@ -81,7 +92,7 @@ export default function ContentCollection({
81
92
  ? `${gap}px`
82
93
  : `var(--kol-gap-wall-${form === 'list' ? 'list' : 'grid'})`
83
94
 
84
- const colsCls = form !== 'list' && COLS[cols] ? COLS[cols] : ''
95
+ const colsCls = form !== 'list' ? colsClasses(cols) : ''
85
96
  return (
86
97
  <ul
87
98
  key={form}
@@ -1,122 +1,12 @@
1
- import { useId, useState } from 'react'
2
- import Input from '../atoms/Input.jsx'
3
- import Button from '../atoms/Button.jsx'
1
+ import SectionNewsletter from './SectionNewsletter.jsx'
4
2
 
5
3
  /**
6
- * NewsletterBand — centered newsletter-subscribe band: heading + lede over an
7
- * email Input + submit Button, with an inline success/error status line under
8
- * the form. The closing subscribe section on marketing pages.
9
- *
10
- * Owns only the local form state — the email value and idle / submitting /
11
- * success / error, driven by the promise returned from `onSubmit(email)`.
12
- * No fetch lives here: the consumer's handler posts wherever it wants;
13
- * resolve → success (field clears), reject → error (logged). An empty submit
14
- * errors immediately without calling the handler, and the submit Button
15
- * disables while the promise is pending. Native `type="email"` validation
16
- * still gates malformed addresses before the handler runs.
17
- *
18
- * A11y: the Input carries aria-required and, on error, aria-describedby
19
- * pointing at the error line; the success line is role=status
20
- * aria-live=polite, the error line role=alert aria-live=assertive. Field and
21
- * error ids are useId-generated (or `inputId`) so multiple bands mount
22
- * without collisions.
23
- *
24
- * @param {ReactNode} title heading (kol-sans-display-01)
25
- * @param {ReactNode} description lede under the heading (kol-mono-14)
26
- * @param {string} placeholder email Input placeholder
27
- * @param {ReactNode} submitLabel submit Button label (also its accessible name)
28
- * @param {Function} onSubmit (email) => Promise|void — resolve = success, reject = error
29
- * @param {ReactNode} successCopy success status line
30
- * @param {ReactNode} errorCopy error status line
31
- * @param {string} id anchor id on the section (e.g. "signup")
32
- * @param {string} inputId id override for the email input (default useId-generated)
33
- * @param {string} className extra classes on the section
4
+ * @deprecated 2026-08-27 `NewsletterBand` is `SectionNewsletter` under its
5
+ * old name: the newsletter joined the section family (SectionNewsletter,
6
+ * kol-website). `title` `headline`, `description` `body`; everything else
7
+ * passes through. On the retirement ledger (04-retirements.md) — dropped when
8
+ * no repo imports it.
34
9
  */
35
- export default function NewsletterBand({
36
- title,
37
- description,
38
- placeholder,
39
- submitLabel,
40
- onSubmit,
41
- successCopy = 'Thanks for subscribing!',
42
- errorCopy = 'Please enter a valid email address.',
43
- id,
44
- inputId,
45
- className = '',
46
- }) {
47
- const [email, setEmail] = useState('')
48
- const [status, setStatus] = useState('idle') // 'idle' | 'submitting' | 'success' | 'error'
49
-
50
- const autoId = useId()
51
- const emailId = inputId ?? `${autoId}email`
52
- const errorId = `${emailId}-error`
53
-
54
- const handleSubmit = async (e) => {
55
- e.preventDefault()
56
-
57
- if (!email) {
58
- setStatus('error')
59
- return
60
- }
61
- if (!onSubmit) return
62
-
63
- setStatus('submitting')
64
- try {
65
- await onSubmit(email)
66
- setStatus('success')
67
- setEmail('')
68
- } catch (error) {
69
- console.error('Newsletter signup error:', error)
70
- setStatus('error')
71
- }
72
- }
73
-
74
- return (
75
- <section
76
- id={id}
77
- className={`w-full flex flex-col items-center justify-center text-center ${className}`.trim()}
78
- >
79
- <div className="max-w-[1400px] mx-auto py-24">
80
- <h2 className="kol-sans-display-01 mb-6">{title}</h2>
81
-
82
- <p className="kol-mono-14 text-auto mb-12 mx-auto max-w-[64rem]">{description}</p>
83
-
84
- <form
85
- onSubmit={handleSubmit}
86
- className="flex flex-col gap-4 mb-16 sm:flex-row sm:items-center sm:justify-center sm:gap-3"
87
- >
88
- <Input
89
- id={emailId}
90
- type="email"
91
- placeholder={placeholder}
92
- value={email}
93
- onChange={(e) => setEmail(e.target.value)}
94
- size="md"
95
- aria-required="true"
96
- aria-describedby={status === 'error' ? errorId : undefined}
97
- className="w-full sm:max-w-[400px] md:max-w-[520px]"
98
- />
99
- <Button
100
- type="submit"
101
- variant="primary"
102
- disabled={status === 'submitting'}
103
- className="w-full sm:w-auto"
104
- >
105
- {submitLabel}
106
- </Button>
107
- </form>
108
-
109
- {status === 'success' && (
110
- <p className="kol-mono-14 text-auto opacity-80" role="status" aria-live="polite">
111
- {successCopy}
112
- </p>
113
- )}
114
- {status === 'error' && (
115
- <p id={errorId} className="kol-mono-14 text-auto opacity-80" role="alert" aria-live="assertive">
116
- {errorCopy}
117
- </p>
118
- )}
119
- </div>
120
- </section>
121
- )
10
+ export default function NewsletterBand({ title, description, ...rest }) {
11
+ return <SectionNewsletter headline={title} body={description} {...rest} />
122
12
  }
@@ -0,0 +1,153 @@
1
+ import { useId, useState } from 'react'
2
+ import Input from '../atoms/Input.jsx'
3
+ import Button from '../atoms/Button.jsx'
4
+ import SectionText from '../molecules/SectionText.jsx'
5
+ import useSectionTheme from '../hooks/useSectionTheme.js'
6
+ import { minHeightClass } from './sectionHeights.js'
7
+
8
+ /**
9
+ * SectionNewsletter — the newsletter card of the section family
10
+ * (SectionNewsletter, kol-website 2026-08-27 — user: the newsletter as a card
11
+ * in the family, same ladder, same cap, content centred on y, not its own
12
+ * thing). `SectionText` (label · headline · body, centred) with the email
13
+ * form in its children slot: an email Input + submit Button and an inline
14
+ * success / error status line. `NewsletterBand` is the deprecated alias
15
+ * (`title` → `headline`, `description` → `body`).
16
+ *
17
+ * Owns only the local form state — the email value and idle / submitting /
18
+ * success / error, driven by the promise returned from `onSubmit(email)`.
19
+ * No fetch lives here: the consumer's handler posts wherever it wants;
20
+ * resolve → success (field clears), reject → error (logged). An empty submit
21
+ * errors immediately without calling the handler, and the submit Button
22
+ * disables while the promise is pending. Native `type="email"` validation
23
+ * still gates malformed addresses before the handler runs.
24
+ *
25
+ * A11y: the Input carries aria-required and, on error, aria-describedby
26
+ * pointing at the error line; the success line is role=status
27
+ * aria-live=polite, the error line role=alert aria-live=assertive. Field and
28
+ * error ids are useId-generated (or `inputId`) so multiple sections mount
29
+ * without collisions.
30
+ *
31
+ * @param {'full'|'80'|'60'|'40'|string} [height='60'] min-height on the family's ladder — full = 100dvh,
32
+ * 80 = 70svh / 80vh, 60 = 50svh / 60vh (default), 40 = 35svh / 40vh; content stays vertically centred inside it
33
+ * @param {'inverse'|'light'|'dark'} theme the paired theme of whatever is live, or a pinned one — stamped on the section
34
+ * @param {ReactNode} label eyebrow above the headline (uppercase by role)
35
+ * @param {ReactNode} headline heading (display-01 by default; `headlineSize` picks another role)
36
+ * @param {string} headlineSize SectionText role (default 'display-01')
37
+ * @param {ReactNode} body lede under the heading
38
+ * @param {string} placeholder email Input placeholder
39
+ * @param {ReactNode} submitLabel submit Button label (also its accessible name)
40
+ * @param {Function} onSubmit (email) => Promise|void — resolve = success, reject = error
41
+ * @param {ReactNode} successCopy success status line
42
+ * @param {ReactNode} errorCopy error status line
43
+ * @param {string} id anchor id on the section (e.g. "signup")
44
+ * @param {string} inputId id override for the email input (default useId-generated)
45
+ * @param {object} slotClass · slotStyle per-slot class / style on the SectionText (reveal seam)
46
+ * @param {string} className extra classes on the section
47
+ */
48
+ export default function SectionNewsletter({
49
+ height = '60',
50
+ theme,
51
+ label,
52
+ headline,
53
+ headlineSize = 'display-01',
54
+ body,
55
+ placeholder,
56
+ submitLabel,
57
+ onSubmit,
58
+ successCopy = 'Thanks for subscribing!',
59
+ errorCopy = 'Please enter a valid email address.',
60
+ id,
61
+ inputId,
62
+ slotClass,
63
+ slotStyle,
64
+ className = '',
65
+ }) {
66
+ const [email, setEmail] = useState('')
67
+ const [status, setStatus] = useState('idle') // 'idle' | 'submitting' | 'success' | 'error'
68
+ const [themeRef, themeStamp] = useSectionTheme(theme)
69
+
70
+ const autoId = useId()
71
+ const emailId = inputId ?? `${autoId}email`
72
+ const errorId = `${emailId}-error`
73
+
74
+ const handleSubmit = async (e) => {
75
+ e.preventDefault()
76
+
77
+ if (!email) {
78
+ setStatus('error')
79
+ return
80
+ }
81
+ if (!onSubmit) return
82
+
83
+ setStatus('submitting')
84
+ try {
85
+ await onSubmit(email)
86
+ setStatus('success')
87
+ setEmail('')
88
+ } catch (error) {
89
+ console.error('Newsletter signup error:', error)
90
+ setStatus('error')
91
+ }
92
+ }
93
+
94
+ return (
95
+ <section
96
+ id={id}
97
+ ref={themeRef}
98
+ data-theme={themeStamp}
99
+ className={`kol-section-newsletter w-full flex flex-col justify-center py-24 ${theme ? 'bg-surface-primary text-auto' : ''} ${minHeightClass(height)} ${className}`.replace(/\s+/g, ' ').trim()}
100
+ >
101
+ {/* the family's ONE cap — the shell's --kol-container-max ladder */}
102
+ <div className="w-full max-w-[var(--kol-container-max,var(--kol-content-shell,1800px))] mx-auto text-center">
103
+ <SectionText
104
+ align="center"
105
+ label={label}
106
+ headline={headline}
107
+ headlineSize={headlineSize}
108
+ body={body}
109
+ bodyClass="kol-mono-14 text-auto mx-auto max-w-[64rem]"
110
+ gap="gap-6"
111
+ slotClass={slotClass}
112
+ slotStyle={slotStyle}
113
+ >
114
+ <form
115
+ onSubmit={handleSubmit}
116
+ className="flex flex-col gap-4 pt-6 sm:flex-row sm:items-center sm:justify-center sm:gap-3"
117
+ >
118
+ <Input
119
+ id={emailId}
120
+ type="email"
121
+ placeholder={placeholder}
122
+ value={email}
123
+ onChange={(e) => setEmail(e.target.value)}
124
+ size="md"
125
+ aria-required="true"
126
+ aria-describedby={status === 'error' ? errorId : undefined}
127
+ className="w-full sm:max-w-[400px] md:max-w-[520px]"
128
+ />
129
+ <Button
130
+ type="submit"
131
+ variant="primary"
132
+ disabled={status === 'submitting'}
133
+ className="w-full sm:w-auto"
134
+ >
135
+ {submitLabel}
136
+ </Button>
137
+ </form>
138
+
139
+ {status === 'success' && (
140
+ <p className="kol-mono-14 text-auto opacity-80 pt-4" role="status" aria-live="polite">
141
+ {successCopy}
142
+ </p>
143
+ )}
144
+ {status === 'error' && (
145
+ <p id={errorId} className="kol-mono-14 text-auto opacity-80 pt-4" role="alert" aria-live="assertive">
146
+ {errorCopy}
147
+ </p>
148
+ )}
149
+ </SectionText>
150
+ </div>
151
+ </section>
152
+ )
153
+ }