@kolkrabbi/kol-component 0.152.0 → 0.154.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 +1 -1
- package/src/index.js +1 -0
- package/src/molecules/ProfileCard.jsx +204 -0
- package/src/molecules/ShellDrawer.jsx +24 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-component",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.154.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
|
@@ -148,6 +148,7 @@ export { default as PlaybackBar } from './molecules/PlaybackBar.jsx'
|
|
|
148
148
|
export { default as AudioSheet } from './molecules/AudioSheet.jsx'
|
|
149
149
|
export { default as DocPage } from './molecules/DocPage.jsx'
|
|
150
150
|
export { default as DocFrontmatter } from './molecules/DocFrontmatter.jsx'
|
|
151
|
+
export { default as ProfileCard } from './molecules/ProfileCard.jsx'
|
|
151
152
|
export { parseFrontmatter } from './utilities/frontmatter.js'
|
|
152
153
|
export { readCover } from './utilities/id3.js'
|
|
153
154
|
export { kindOf, extOf, isSystemFile, KINDS, KIND_LABEL, DEFAULT_KINDS, isSegment, groupSegments, groupVariants, posterFor, partition } from './utilities/mediaKinds.js'
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { useId, useState } from 'react'
|
|
2
|
+
import { Icon } from '@kolkrabbi/kol-icons'
|
|
3
|
+
import IconFrame from '../atoms/IconFrame.jsx'
|
|
4
|
+
import { Tooltip } from '../utilities/Popover.jsx'
|
|
5
|
+
|
|
6
|
+
/* taxonomy-ok: nests kol-icons's Icon (a package import the relative-import
|
|
7
|
+
* check can't see). */
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* ProfileCard — the digital namecard (ProfileCard, kol-website 2026-09-01;
|
|
11
|
+
* carried class-for-class from `apps/web/src/components/ui/ProfileCard.jsx`):
|
|
12
|
+
* a square photo with a disclosure control inset on it, and a SHELF that opens
|
|
13
|
+
* under it (vertical) or beside it (horizontal) — logo, name, mailto, a rack of
|
|
14
|
+
* socials. The shelf is `bg-surface-inverse` / `text-auto-inverse` at every
|
|
15
|
+
* theme: the inverse of the page is the design, not a bug.
|
|
16
|
+
*
|
|
17
|
+
* THE SHELF SIZES TO ITS CONTENT. The source held it in a fixed box
|
|
18
|
+
* (`h-60`/`h-44`/`h-32`/`h-24`, `overflow-hidden`) and every vertical size
|
|
19
|
+
* clipped its own socials rack on a phone — lg needed 204px inside 176, sm 140
|
|
20
|
+
* inside 96. The consumer patched a `min-h` floor under a raised `max-height`
|
|
21
|
+
* ceiling; that is two guesses at one number. Here the open state animates
|
|
22
|
+
* `grid-template-rows: 0fr → 1fr` — the browser measures, nothing is guessed,
|
|
23
|
+
* and a sixth social or a longer name never clips. Horizontal keeps the user's
|
|
24
|
+
* 2026-08-27 ruling — the CARD holds a fixed square and the PHOTO crops as the
|
|
25
|
+
* shelf comes in — as `grid-template-columns: 0px → 224px`, ONE number where
|
|
26
|
+
* the source carried three (`w-56`, `'320px'`, an inline `'224px'` — only the
|
|
27
|
+
* last was ever read).
|
|
28
|
+
*
|
|
29
|
+
* SCALE AND WIDTH ARE SEPARATE. `size` drives the ramp — logo height, mono
|
|
30
|
+
* step, glyph size, rack gap, control inset — and caps the width
|
|
31
|
+
* (`max-w-[680/480/320/200]`); the card is `w-full` inside that cap, so a
|
|
32
|
+
* caller's `className="w-full"` no longer fights a second width utility.
|
|
33
|
+
*
|
|
34
|
+
* THE DISCLOSURE IS A BUTTON, NOT A SWITCH. The source opened the shelf with
|
|
35
|
+
* `ToggleSwitch` — a form control that states a persistent setting, used for a
|
|
36
|
+
* reveal that sets nothing. Ruling applied here rather than minted as a second
|
|
37
|
+
* component: a disclosure is a button carrying `aria-expanded` +
|
|
38
|
+
* `aria-controls`, `plus` closed / `minus` open. Over MEDIA it is
|
|
39
|
+
* `IconFrame variant="secondary" radius="full"` — the glyph needs a surface
|
|
40
|
+
* (the bare `nav` glyph was measured invisible over the photo: page ink on a
|
|
41
|
+
* dark picture); on a surface the bare `nav` idiom the close X standardised
|
|
42
|
+
* on (FullscreenOverlayCloseIdiom) is the same ruling with no frame.
|
|
43
|
+
*
|
|
44
|
+
* Brand content is the CONSUMER'S: `logo` is a slot (the source baked
|
|
45
|
+
* `kol-brand`'s lockup, which this package cannot import), `name` / `email` /
|
|
46
|
+
* `socials` are props with no defaults.
|
|
47
|
+
*
|
|
48
|
+
* @param {string} image photo URL — square, `object-cover object-top`
|
|
49
|
+
* @param {string} alt the photo's alt (default: `name`)
|
|
50
|
+
* @param {ReactNode} logo the lockup — sized by the ramp (`[&>svg]:h-full`), `text-auto-inverse`
|
|
51
|
+
* @param {string} name the person
|
|
52
|
+
* @param {string} email rendered as a `mailto:`
|
|
53
|
+
* @param {Array} socials `[{ icon, href, label }]` — one Icon-in-Tooltip each, external links
|
|
54
|
+
* @param {'xl'|'lg'|'md'|'sm'} size the ramp + width cap (680 · 480 · 320 · 200); default xl
|
|
55
|
+
* @param {'vertical'|'horizontal'} orientation shelf under the photo (card grows) or beside it (card holds its square, photo crops)
|
|
56
|
+
* @param {string} variant alias for the source's names — `'lg-h'` = `size="lg" orientation="horizontal"`, any other value = `size`
|
|
57
|
+
* @param {boolean} open controlled open state
|
|
58
|
+
* @param {boolean} defaultOpen uncontrolled initial state (default false)
|
|
59
|
+
* @param {Function} onOpenChange `(open) => void`
|
|
60
|
+
* @param {string} className extra classes on the card
|
|
61
|
+
*/
|
|
62
|
+
|
|
63
|
+
const SIZES = {
|
|
64
|
+
xl: { cap: 'max-w-[680px]', pad: 'p-6', logo: 'h-20', text: 'kol-mono-16', iconSize: 24, iconBox: 'w-8 h-8', iconGap: 'gap-2', control: 'bottom-4 left-4' },
|
|
65
|
+
lg: { cap: 'max-w-[480px]', pad: 'p-5', logo: 'h-14', text: 'kol-mono-14', iconSize: 20, iconBox: 'w-7 h-7', iconGap: 'gap-1.5', control: 'bottom-3 left-3' },
|
|
66
|
+
md: { cap: 'max-w-[320px]', pad: 'p-4', logo: 'h-10', text: 'kol-mono-12', iconSize: 16, iconBox: 'w-6 h-6', iconGap: 'gap-1', control: 'bottom-3 left-3' },
|
|
67
|
+
sm: { cap: 'max-w-[200px]', pad: 'p-3', logo: 'h-7', text: 'kol-mono-10', iconSize: 12, iconBox: 'w-5 h-5', iconGap: 'gap-1', control: 'bottom-2 left-2' },
|
|
68
|
+
}
|
|
69
|
+
/* the source's `lg-h`: lg's ramp with a 16px glyph in a 24 box, p-4, and the
|
|
70
|
+
* one open-shelf width */
|
|
71
|
+
const HORIZONTAL = { pad: 'p-4', iconSize: 16, iconBox: 'w-6 h-6', shelf: 224 }
|
|
72
|
+
const SHADOW = '0 112px 192px -80px rgba(0,0,0,0.6)'
|
|
73
|
+
|
|
74
|
+
export default function ProfileCard({
|
|
75
|
+
image,
|
|
76
|
+
alt,
|
|
77
|
+
logo,
|
|
78
|
+
name,
|
|
79
|
+
email,
|
|
80
|
+
socials = [],
|
|
81
|
+
size = 'xl',
|
|
82
|
+
orientation = 'vertical',
|
|
83
|
+
variant,
|
|
84
|
+
open,
|
|
85
|
+
defaultOpen = false,
|
|
86
|
+
onOpenChange,
|
|
87
|
+
className = '',
|
|
88
|
+
}) {
|
|
89
|
+
/* the alias maps and gains nothing (gate R4) */
|
|
90
|
+
const horizontal = variant === 'lg-h' || (variant == null && orientation === 'horizontal')
|
|
91
|
+
const s = SIZES[variant === 'lg-h' ? 'lg' : (variant ?? size)] ?? SIZES.xl
|
|
92
|
+
const ramp = horizontal ? { ...s, ...HORIZONTAL } : s
|
|
93
|
+
|
|
94
|
+
const [localOpen, setLocalOpen] = useState(defaultOpen)
|
|
95
|
+
const isOpen = open ?? localOpen
|
|
96
|
+
const setOpen = (next) => {
|
|
97
|
+
if (open == null) setLocalOpen(next)
|
|
98
|
+
onOpenChange?.(next)
|
|
99
|
+
}
|
|
100
|
+
const shelfId = useId()
|
|
101
|
+
|
|
102
|
+
const control = (
|
|
103
|
+
<div className={`absolute ${ramp.control}`}>
|
|
104
|
+
<IconFrame
|
|
105
|
+
name={isOpen ? 'minus' : 'plus'}
|
|
106
|
+
variant="secondary"
|
|
107
|
+
size="md"
|
|
108
|
+
radius="full"
|
|
109
|
+
aria-expanded={isOpen}
|
|
110
|
+
aria-controls={shelfId}
|
|
111
|
+
aria-label={isOpen ? 'Hide contact details' : 'Show contact details'}
|
|
112
|
+
onClick={() => setOpen(!isOpen)}
|
|
113
|
+
/>
|
|
114
|
+
</div>
|
|
115
|
+
)
|
|
116
|
+
const photo = (
|
|
117
|
+
<img src={image} alt={alt ?? name ?? ''} className="w-full h-full object-cover object-top" loading="lazy" />
|
|
118
|
+
)
|
|
119
|
+
const lockup = logo != null && (
|
|
120
|
+
<div className={`inline-flex ${ramp.logo} text-auto-inverse [&>svg]:h-full [&>svg]:w-auto`}>{logo}</div>
|
|
121
|
+
)
|
|
122
|
+
const contact = (
|
|
123
|
+
<div className={`flex flex-col ${horizontal ? 'gap-1.5' : 'gap-1'} antialiased`} style={{ lineHeight: 1 }}>
|
|
124
|
+
{name && <span className={`${ramp.text} text-auto-inverse`}>{name}</span>}
|
|
125
|
+
{email && (
|
|
126
|
+
<a
|
|
127
|
+
href={`mailto:${email}`}
|
|
128
|
+
className={`${ramp.text} text-auto-inverse underline decoration-2 decoration-transparent hover:decoration-current transition-all`}
|
|
129
|
+
>
|
|
130
|
+
{email}
|
|
131
|
+
</a>
|
|
132
|
+
)}
|
|
133
|
+
</div>
|
|
134
|
+
)
|
|
135
|
+
const rack = (
|
|
136
|
+
<div className={`flex ${horizontal ? 'flex-row' : 'flex-col items-start'} ${ramp.iconGap}`}>
|
|
137
|
+
{socials.map(({ icon, href, label }) => (
|
|
138
|
+
<Tooltip key={icon} label={label}>
|
|
139
|
+
<a
|
|
140
|
+
href={href}
|
|
141
|
+
target="_blank"
|
|
142
|
+
rel="noopener noreferrer"
|
|
143
|
+
aria-label={label}
|
|
144
|
+
className={`${ramp.iconBox} flex items-center justify-center text-auto-inverse transition-transform hover:scale-125`}
|
|
145
|
+
>
|
|
146
|
+
<Icon name={icon} size={ramp.iconSize} />
|
|
147
|
+
</a>
|
|
148
|
+
</Tooltip>
|
|
149
|
+
))}
|
|
150
|
+
</div>
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
if (horizontal) {
|
|
154
|
+
return (
|
|
155
|
+
<div
|
|
156
|
+
className={`grid w-full aspect-square rounded overflow-hidden transition-[grid-template-columns] duration-300 ease-in-out ${className}`}
|
|
157
|
+
style={{ gridTemplateColumns: `${isOpen ? HORIZONTAL.shelf : 0}px minmax(0, 1fr)`, boxShadow: SHADOW }}
|
|
158
|
+
>
|
|
159
|
+
{/* Shelf — LEFT. The inner column holds the open width so the text does
|
|
160
|
+
* not reflow while the track animates. */}
|
|
161
|
+
<div id={shelfId} className="min-w-0 overflow-hidden bg-surface-inverse">
|
|
162
|
+
<div className={`h-full ${ramp.pad} flex flex-col justify-between items-start`} style={{ width: HORIZONTAL.shelf }}>
|
|
163
|
+
{lockup}
|
|
164
|
+
<div className="flex flex-col gap-8">
|
|
165
|
+
{contact}
|
|
166
|
+
{rack}
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
169
|
+
</div>
|
|
170
|
+
{/* Photo — RIGHT: crops as the shelf comes in, the card never moves */}
|
|
171
|
+
<div className="relative min-w-0 h-full overflow-hidden bg-surface-secondary">
|
|
172
|
+
{photo}
|
|
173
|
+
{control}
|
|
174
|
+
</div>
|
|
175
|
+
</div>
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return (
|
|
180
|
+
<div className={`w-full ${ramp.cap} rounded overflow-hidden ${className}`} style={{ boxShadow: SHADOW }}>
|
|
181
|
+
{/* Photo — square */}
|
|
182
|
+
<div className="relative w-full aspect-square overflow-hidden bg-surface-secondary">
|
|
183
|
+
{photo}
|
|
184
|
+
{control}
|
|
185
|
+
</div>
|
|
186
|
+
{/* Shelf — content-sized: the row track animates 0fr → 1fr and the browser
|
|
187
|
+
* measures; `min-h-0 overflow-hidden` on the item is what lets 0fr close */}
|
|
188
|
+
<div
|
|
189
|
+
className="grid transition-[grid-template-rows] duration-300 ease-in-out"
|
|
190
|
+
style={{ gridTemplateRows: isOpen ? '1fr' : '0fr' }}
|
|
191
|
+
>
|
|
192
|
+
<div id={shelfId} className="min-h-0 overflow-hidden bg-surface-inverse">
|
|
193
|
+
<div className={`${ramp.pad} flex justify-between items-end`}>
|
|
194
|
+
<div className="self-stretch flex flex-col justify-between items-start">
|
|
195
|
+
{lockup}
|
|
196
|
+
{contact}
|
|
197
|
+
</div>
|
|
198
|
+
{rack}
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
201
|
+
</div>
|
|
202
|
+
</div>
|
|
203
|
+
)
|
|
204
|
+
}
|
|
@@ -12,7 +12,7 @@ const FOCUSABLE =
|
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* ShellDrawer — THE edge drawer: a portalled panel that slides in from the
|
|
15
|
-
* left or
|
|
15
|
+
* left, right or BOTTOM viewport edge over a dimming backdrop. Distinct from Modal
|
|
16
16
|
* (centered prompt/confirm) and FullscreenOverlay (fills the whole viewport,
|
|
17
17
|
* not an edge sheet). Escape, backdrop click and the built-in close button
|
|
18
18
|
* all call `onClose`; body scroll locks while open; focus moves into the
|
|
@@ -26,8 +26,16 @@ const FOCUSABLE =
|
|
|
26
26
|
*
|
|
27
27
|
* @param {boolean} open drawer visible (drives slide in/out)
|
|
28
28
|
* @param {Function} onClose close request (Esc / backdrop / close button)
|
|
29
|
-
* @param {string} side 'left' | 'right' — edge the panel slides from
|
|
30
|
-
*
|
|
29
|
+
* @param {string} side 'left' | 'right' | 'bottom' — edge the panel slides from.
|
|
30
|
+
* `bottom` (ShellDrawerBottomSide, kol-mirror 2026-09-01) is the
|
|
31
|
+
* phone's sheet: full viewport width, slides up from +100% on Y,
|
|
32
|
+
* takes `height` where the sides take `width`, and pads its foot by
|
|
33
|
+
* `env(safe-area-inset-bottom)` so the last row clears the home bar.
|
|
34
|
+
* ONE DETENT — open or closed. A collapsed bar that grows on tap is
|
|
35
|
+
* a second height the consumer owns (mirror's 56px → 68dvh); this
|
|
36
|
+
* sheet does not carry it, and says so rather than half-build it.
|
|
37
|
+
* @param {number|string} width panel width (px number or CSS length); omit for full-width sheet — sides only
|
|
38
|
+
* @param {number|string} height panel height (px number or CSS length); omit for a content-sized sheet — `bottom` only
|
|
31
39
|
* @param {ReactNode} header header-row content beside the close button (replaces the source's baked-in wordmark)
|
|
32
40
|
* @param {boolean} backdrop render the dimming scrim (default true); false = panel alone, no darken/blur, close via × / Esc
|
|
33
41
|
* @param {ReactNode} children scrollable panel body
|
|
@@ -38,6 +46,7 @@ export default function ShellDrawer({
|
|
|
38
46
|
onClose,
|
|
39
47
|
side = 'left',
|
|
40
48
|
width,
|
|
49
|
+
height,
|
|
41
50
|
header,
|
|
42
51
|
closeSide = 'end',
|
|
43
52
|
backdrop = true,
|
|
@@ -130,10 +139,18 @@ export default function ShellDrawer({
|
|
|
130
139
|
|
|
131
140
|
if (!present || typeof document === 'undefined') return null
|
|
132
141
|
|
|
133
|
-
const
|
|
142
|
+
const bottom = side === 'bottom'
|
|
143
|
+
const slideOut = bottom ? 'translate-y-full' : side === 'right' ? 'translate-x-full' : '-translate-x-full'
|
|
134
144
|
const motionPanel = reduced
|
|
135
145
|
? ''
|
|
136
|
-
: `transition-transform duration-200 ease-out ${shown ? 'translate-x-0' : slideOut}`
|
|
146
|
+
: `transition-transform duration-200 ease-out ${shown ? (bottom ? 'translate-y-0' : 'translate-x-0') : slideOut}`
|
|
147
|
+
/* the sheet's edge is the top; the sides' is the inner vertical */
|
|
148
|
+
const place = bottom
|
|
149
|
+
? `inset-x-0 bottom-0 w-full max-h-full ${edge ? 'border-t' : ''}`
|
|
150
|
+
: `inset-y-0 max-w-full ${side === 'right' ? `right-0 ${edge ? 'border-l' : ''}` : `left-0 ${edge ? 'border-r' : ''}`} ${width == null ? 'w-full' : ''}`
|
|
151
|
+
const size = bottom
|
|
152
|
+
? { ...(height != null ? { height: typeof height === 'number' ? `${height}px` : height } : {}), paddingBottom: 'calc(1rem + env(safe-area-inset-bottom, 0px))' }
|
|
153
|
+
: width != null ? { width: typeof width === 'number' ? `${width}px` : width } : undefined
|
|
137
154
|
const motionBackdrop = reduced
|
|
138
155
|
? ''
|
|
139
156
|
: `transition-opacity duration-200 ease-out ${shown ? 'opacity-100' : 'opacity-0'}`
|
|
@@ -159,10 +176,8 @@ export default function ShellDrawer({
|
|
|
159
176
|
tabIndex={-1}
|
|
160
177
|
/* `edge` / `shadow` (SettingsPanelApproved, 2026-08-27): the approved settings
|
|
161
178
|
* drawer has neither — the sheet meets the page flat */
|
|
162
|
-
className={`fixed
|
|
163
|
-
|
|
164
|
-
} border-oq-08 ${width == null ? 'w-full' : ''} ${motionPanel} ${className}`}
|
|
165
|
-
style={width != null ? { width: typeof width === 'number' ? `${width}px` : width } : undefined}
|
|
179
|
+
className={`fixed z-[200] flex flex-col bg-surface-primary px-4 py-4 outline-none md:px-5 lg:px-6 ${backdrop && shadow ? 'shadow-2xl' : ''} ${place} border-oq-08 ${motionPanel} ${className}`}
|
|
180
|
+
style={size}
|
|
166
181
|
>
|
|
167
182
|
{/* closeSide="start": the reference sets the × glyph ~9px deeper than
|
|
168
183
|
* the label column's edge, with extra top air (both reference frames
|