@kolkrabbi/kol-component 0.5.0 → 0.6.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.5.0",
3
+ "version": "0.6.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",
@@ -24,7 +24,7 @@
24
24
  "dependencies": {
25
25
  "@floating-ui/react": "^0.27.19",
26
26
  "embla-carousel-react": "^8.6.0",
27
- "@kolkrabbi/kol-icons": "0.4.0"
27
+ "@kolkrabbi/kol-icons": "0.5.0"
28
28
  },
29
29
  "peerDependencies": {
30
30
  "react": "^18.3.0 || ^19.0.0",
@@ -82,11 +82,15 @@ const Button = ({
82
82
 
83
83
  // Add kol-btn-animate class if animateIcon is true to disable default hover states
84
84
  const animateClass = animateIcon ? 'kol-btn-animate' : ''
85
- const quietClass = quiet && !pressed ? 'kol-btn-quiet' : ''
86
- const selectedClass = selected ? 'kol-btn-selected' : ''
87
- const pressedClass = pressed ? 'kol-btn-pressed' : ''
88
-
89
- const combinedClass = `kol-btn ${variantClass} ${sizeClass} ${animateClass} ${quietClass} ${selectedClass} ${pressedClass} ${className}`.trim().replace(/\s+/g, ' ')
85
+ // `selected` is a legacy alias of `pressed` both express the aria-pressed
86
+ // toggle-on state and render through .kol-btn-pressed. (The old
87
+ // .kol-btn-selected class never had a CSS rule, so `selected` was a no-op.)
88
+ // Resolve to one flag so the quiet-drop and the pressed fill agree.
89
+ const isPressed = pressed !== undefined ? pressed : selected
90
+ const quietClass = quiet && !isPressed ? 'kol-btn-quiet' : ''
91
+ const pressedClass = isPressed ? 'kol-btn-pressed' : ''
92
+
93
+ const combinedClass = `kol-btn ${variantClass} ${sizeClass} ${animateClass} ${quietClass} ${pressedClass} ${className}`.trim().replace(/\s+/g, ' ')
90
94
 
91
95
  // Render icon with optional hover state
92
96
  const renderIcon = (iconName, iconHoverName) => {
@@ -3,11 +3,11 @@ import { Icon } from '@kolkrabbi/kol-icons'
3
3
  /**
4
4
  * Input — single-input atom built on the .kol-control shell.
5
5
  *
6
- * variant="filled" (default) — persistent solid bg
7
- * variant="ghost" borderless at rest, reveals on hover/focus
8
- * variant="outline" — bordered, transparent bg (also: the
9
- * forced-reveal / "is-edited" state of a
10
- * ghost field flip variant to outline)
6
+ * variant="filled" (default) — persistent solid bg (= button primary chrome)
7
+ * variant="outline" bordered, transparent bg THE secondary
8
+ * treatment (2026-07-08 chrome law: one
9
+ * secondary, always subordinate to filled)
10
+ * variant="ghost" — legacy alias, resolves to outline
11
11
  *
12
12
  * size="sm" / "md" (default) / "lg" — matched padding + type class
13
13
  *
@@ -57,9 +57,12 @@ export default function Input({
57
57
  const fixedChars = typeof chars === 'number'
58
58
  const resolvedIconSize = iconSize ?? ICON_SIZE[size] ?? 14
59
59
 
60
+ // ghost folds into outline (2026-07-08 chrome law): one secondary treatment.
61
+ const resolvedVariant = variant === 'ghost' ? 'outline' : variant
62
+
60
63
  const shellCls = [
61
64
  'kol-control',
62
- `kol-control--${variant}`,
65
+ `kol-control--${resolvedVariant}`,
63
66
  `kol-control-${size}`,
64
67
  SIZE_TYPE[size],
65
68
  'cursor-text',
@@ -1,3 +1,4 @@
1
+
1
2
  import { Icon } from '@kolkrabbi/kol-icons'
2
3
 
3
4
  /**
@@ -11,8 +12,10 @@ import { Icon } from '@kolkrabbi/kol-icons'
11
12
  * size="sm" / "md" (default) / "lg" — padding + type class
12
13
  *
13
14
  * Default rows = 3 (kept short — long content gets scrollbars instead of
14
- * dominating the panel). No native resize handle fixed size, scrollable
15
- * overflow. The kol resize-corner icon in the bottom-right is decorative.
15
+ * dominating the panel). Resize is real (2026-07-08): native resize is
16
+ * OFF (Firefox's built-in grip cannot be hidden any other way) and the
17
+ * kol-icon-set-v1 `resize-grip` icon IS the drag handle — corner drag,
18
+ * both axes, min 120×40. One grip, every browser.
16
19
  *
17
20
  * Controlled OR uncontrolled:
18
21
  * - pass `value` + `onChange` for controlled,
@@ -34,9 +37,12 @@ export default function Textarea({
34
37
  className = '',
35
38
  ...props
36
39
  }) {
40
+ // ghost folds into outline (2026-07-08 chrome law): one secondary treatment.
41
+ const resolvedVariant = variant === 'ghost' ? 'outline' : variant
42
+
37
43
  const wrapperCls = [
38
44
  'kol-control',
39
- `kol-control--${variant}`,
45
+ `kol-control--${resolvedVariant}`,
40
46
  'kol-control--textarea',
41
47
  `kol-control-${size}`,
42
48
  SIZE_TYPE[size],
@@ -62,8 +68,33 @@ export default function Textarea({
62
68
  style={{ resize: 'none' }}
63
69
  {...props}
64
70
  />
65
- <span aria-hidden="true" className="kol-textarea-resize-icon text-meta pointer-events-none">
66
- <Icon name="resize-corner" size={12} />
71
+ <span
72
+ aria-hidden="true"
73
+ className="kol-textarea-resize-icon"
74
+ onPointerDown={(e) => {
75
+ // The grip IS the resize handle — corner drag, both axes
76
+ // (min 120×40). Width lands on the shell, height on the textarea.
77
+ const shell = e.currentTarget.parentElement
78
+ const ta = shell?.querySelector('textarea')
79
+ if (!shell || !ta) return
80
+ e.preventDefault()
81
+ const startX = e.clientX
82
+ const startY = e.clientY
83
+ const startW = shell.offsetWidth
84
+ const startH = ta.offsetHeight
85
+ const move = (ev) => {
86
+ shell.style.width = `${Math.max(startW + ev.clientX - startX, 120)}px`
87
+ ta.style.height = `${Math.max(startH + ev.clientY - startY, 40)}px`
88
+ }
89
+ const up = () => {
90
+ window.removeEventListener('pointermove', move)
91
+ window.removeEventListener('pointerup', up)
92
+ }
93
+ window.addEventListener('pointermove', move)
94
+ window.addEventListener('pointerup', up)
95
+ }}
96
+ >
97
+ <Icon name="resize-grip" size={12} />
67
98
  </span>
68
99
  </label>
69
100
  )
@@ -1,11 +1,30 @@
1
1
  import React from 'react'
2
2
 
3
+ /**
4
+ * ToggleSwitch — bare by default (2026-07-08 chrome law rewrite).
5
+ *
6
+ * variant="bare" (default) — label + track, no box
7
+ * variant="primary" — filled shell (surface-secondary), button geometry
8
+ * variant="outline" — bordered shell (border-oq-16), button geometry
9
+ *
10
+ * size="sm" / "md" (default) / "lg" — shells match button heights
11
+ * (26/32/40); the track scales with size in all variants.
12
+ *
13
+ * Legacy aliases: 'plain' → bare · 'default' (the old boxed look) → outline.
14
+ * Type via kol-mono-{12,14,16}; no auto-uppercase — casing is authored at
15
+ * the call site (KOL no-auto-casing rule).
16
+ */
17
+
18
+ const SIZE_TYPE = { sm: 'kol-mono-12', md: 'kol-mono-14', lg: 'kol-mono-16' }
19
+ const LEGACY_VARIANTS = { plain: 'bare', default: 'outline' }
20
+
3
21
  const ToggleSwitch = ({
4
22
  label,
5
23
  checked = false,
6
24
  onChange,
7
25
  onToggle,
8
- variant = 'default',
26
+ variant = 'bare',
27
+ size = 'md',
9
28
  className = '',
10
29
  hint,
11
30
  ...props
@@ -15,12 +34,20 @@ const ToggleSwitch = ({
15
34
  if (onChange) onChange(!checked)
16
35
  }
17
36
 
18
- const variantClass = variant === 'plain' ? 'toggle-switch--plain' : ''
37
+ const resolvedVariant = LEGACY_VARIANTS[variant] || variant
38
+
39
+ const cls = [
40
+ 'toggle-switch',
41
+ `toggle-switch--${resolvedVariant}`,
42
+ `toggle-switch--${size}`,
43
+ SIZE_TYPE[size],
44
+ className,
45
+ ].filter(Boolean).join(' ')
19
46
 
20
47
  return (
21
48
  <button
22
49
  type="button"
23
- className={`toggle-switch ${variantClass} ${className}`.trim()}
50
+ className={cls}
24
51
  data-state={checked ? 'on' : 'off'}
25
52
  onClick={handleClick}
26
53
  aria-pressed={checked}
@@ -29,7 +56,7 @@ const ToggleSwitch = ({
29
56
  <span className="toggle-switch-label">
30
57
  {label}
31
58
  {hint ? (
32
- <span className="ml-2 opacity-60 normal-case tracking-normal kol-helper-10">
59
+ <span className="ml-2 opacity-60 kol-helper-10">
33
60
  {hint}
34
61
  </span>
35
62
  ) : null}
@@ -3,20 +3,34 @@ import { Icon } from '@kolkrabbi/kol-icons'
3
3
  import { MenuDropdownItem } from './MenuItem.jsx'
4
4
  import { PopoverPanel, usePopover } from '../atoms/Popover.jsx'
5
5
 
6
- const SIZE_MAP = {
7
- sm: { fontSize: 12, paddingY: 4, paddingX: 12, radius: 14, panelRadius: 14, icon: 10 },
8
- md: { fontSize: 12, paddingY: 6, paddingX: 16, radius: 22, panelRadius: 22, icon: 12 },
9
- lg: { fontSize: 14, paddingY: 8, paddingX: 20, radius: 24, panelRadius: 24, icon: 14 }
10
- }
11
-
12
- const SIZE_TYPE = { sm: 'kol-mono-12', md: 'kol-mono-12', lg: 'kol-mono-14' }
6
+ /**
7
+ * Dropdown trigger IS button chrome (2026-07-08 chrome law).
8
+ *
9
+ * The trigger emits `kol-btn kol-btn-{variant} kol-btn-{size}` so it renders
10
+ * pixel-identical to a Button of the same variant/size — fills, hover,
11
+ * active, focus ring all come from the button rules in kol-theme.
12
+ * `.kol-dd-*` classes only add trigger layout + the open/panel fusion.
13
+ *
14
+ * variant="primary" (default) — filled trigger; open panel continues the
15
+ * same fill (one piece: no border, no gap, hairline divider inside)
16
+ * variant="outline" — bordered trigger; open panel carries the
17
+ * same border, trigger's bottom edge acts as the divider
18
+ *
19
+ * Legacy aliases (pre-law variants, kept so call-sites don't break):
20
+ * default → primary · subtle → primary · minimal → outline
21
+ */
22
+
23
+ const SIZE_TYPE = { sm: 'kol-mono-12', md: 'kol-mono-14', lg: 'kol-mono-16' }
24
+ const ICON_SIZE = { sm: 14, md: 16, lg: 18 }
25
+
26
+ const LEGACY_VARIANTS = { default: 'primary', subtle: 'primary', minimal: 'outline' }
13
27
 
14
28
  const Dropdown = ({
15
29
  options = [],
16
30
  value,
17
31
  onChange,
18
32
  size,
19
- variant = 'default',
33
+ variant = 'primary',
20
34
  defaultOpen = false,
21
35
  className = ''
22
36
  }) => {
@@ -24,15 +38,17 @@ const Dropdown = ({
24
38
  const [resolvedSize, setResolvedSize] = useState('md')
25
39
  const [dropdownWidth, setDropdownWidth] = useState('100px')
26
40
 
41
+ const resolvedVariant = LEGACY_VARIANTS[variant] || variant
42
+
27
43
  /* Floating-ui popover. `flip: false` keeps the panel below the button
28
- * — the seamless border-radius edge between button and panel assumes
29
- * the panel sits below; flipping above would visually disconnect them.
44
+ * — the seamless fused edge between trigger and panel assumes the panel
45
+ * sits below; flipping above would visually disconnect them.
30
46
  * `matchReferenceWidth: true` pins panel min-width to the button. */
31
47
  const popover = usePopover({
32
48
  open: isOpen,
33
49
  onOpenChange: setIsOpen,
34
50
  placement: 'bottom-start',
35
- offset: variant === 'minimal' ? 0 : -1,
51
+ offset: -1,
36
52
  flip: false,
37
53
  matchReferenceWidth: true,
38
54
  role: 'listbox',
@@ -67,63 +83,23 @@ const Dropdown = ({
67
83
  }
68
84
  }, [size])
69
85
 
70
- // Width management for variants
86
+ // Width management 100px mobile, 140px tablet, 180px desktop
71
87
  useEffect(() => {
72
88
  const updateWidth = () => {
73
89
  if (typeof window === 'undefined') return
74
90
 
75
- if (variant === 'minimal' || variant === 'subtle') {
76
- // Minimal/subtle: 100px mobile, 140px tablet+
77
- setDropdownWidth(window.innerWidth >= 768 ? '140px' : '100px')
78
- } else if (variant === 'default') {
79
- // Default: 100px mobile, 140px tablet, 180px desktop
80
- if (window.innerWidth >= 1024) {
81
- setDropdownWidth('180px')
82
- } else if (window.innerWidth >= 768) {
83
- setDropdownWidth('140px')
84
- } else {
85
- setDropdownWidth('100px')
86
- }
91
+ if (window.innerWidth >= 1024) {
92
+ setDropdownWidth('180px')
93
+ } else if (window.innerWidth >= 768) {
94
+ setDropdownWidth('140px')
95
+ } else {
96
+ setDropdownWidth('100px')
87
97
  }
88
98
  }
89
99
  updateWidth()
90
100
  window.addEventListener('resize', updateWidth)
91
101
  return () => window.removeEventListener('resize', updateWidth)
92
- }, [variant])
93
-
94
- const metrics = SIZE_MAP[resolvedSize] || SIZE_MAP.md
95
-
96
- // Variant-specific styles
97
- const variantStyles = {
98
- default: {
99
- border: '1px solid var(--kol-border-default)',
100
- borderRadius: isOpen
101
- ? `${metrics.radius}px ${metrics.radius}px 0 0`
102
- : `${metrics.radius}px`,
103
- backgroundColor: 'var(--kol-surface-primary)',
104
- padding: `${metrics.paddingY}px ${metrics.paddingX}px`
105
- },
106
- minimal: {
107
- border: 'none',
108
- borderRadius: '0',
109
- backgroundColor: 'transparent',
110
- padding: '0',
111
- height: '24px',
112
- display: 'flex',
113
- alignItems: 'center'
114
- },
115
- subtle: {
116
- /* 1px transparent border so total height matches Button + Input + the
117
- * other shared-control atoms (which have a transparent 1px border for
118
- * hover/focus consistency). Without this, subtle is 2px shorter. */
119
- border: '1px solid transparent',
120
- borderRadius: isOpen ? '4px 4px 0 0' : '4px',
121
- backgroundColor: 'var(--kol-surface-secondary)',
122
- padding: `${metrics.paddingY}px ${metrics.paddingX}px`
123
- }
124
- }
125
-
126
- const styles = variantStyles[variant] || variantStyles.default
102
+ }, [])
127
103
 
128
104
  const handleSelect = (option) => {
129
105
  onChange?.(option.value)
@@ -132,11 +108,20 @@ const Dropdown = ({
132
108
 
133
109
  const currentOption = options.find((opt) => opt.value === value) || options[0]
134
110
 
111
+ const triggerCls = [
112
+ 'kol-btn',
113
+ `kol-btn-${resolvedVariant}`,
114
+ `kol-btn-${resolvedSize}`,
115
+ SIZE_TYPE[resolvedSize],
116
+ 'kol-dd-trigger',
117
+ isOpen && 'kol-dd-trigger--open',
118
+ ].filter(Boolean).join(' ')
119
+
135
120
  return (
136
121
  <div
137
122
  className={`relative block ${className}`}
138
123
  style={{
139
- ...((variant === 'minimal' || variant === 'default' || variant === 'subtle') && dropdownWidth && !className.includes('w-full') && {
124
+ ...(dropdownWidth && !className.includes('w-full') && {
140
125
  width: dropdownWidth,
141
126
  minWidth: dropdownWidth
142
127
  })
@@ -146,26 +131,15 @@ const Dropdown = ({
146
131
  ref={popover.refs.setReference}
147
132
  {...popover.getReferenceProps()}
148
133
  type="button"
149
- className={`w-full flex items-center justify-between transition-colors duration-200 ${SIZE_TYPE[resolvedSize]}`}
150
- style={{
151
- border: styles.border,
152
- borderRadius: styles.borderRadius,
153
- backgroundColor: styles.backgroundColor,
154
- color: 'var(--kol-surface-on-primary)',
155
- padding: styles.padding,
156
- transition: 'background-color 0.2s, color 0.2s, border-color 0.2s',
157
- ...(variant === 'minimal' && {
158
- height: styles.height,
159
- }),
160
- }}
134
+ className={triggerCls}
161
135
  aria-haspopup="listbox"
162
136
  aria-expanded={isOpen}
163
137
  data-state={isOpen ? 'open' : 'closed'}
164
138
  >
165
- <span className="opacity-100">{currentOption?.label}</span>
139
+ <span>{currentOption?.label}</span>
166
140
  <Icon
167
141
  name="chevron-down"
168
- size={metrics.icon}
142
+ size={ICON_SIZE[resolvedSize]}
169
143
  className="ml-auto"
170
144
  style={{
171
145
  transform: isOpen ? 'rotate(180deg)' : 'rotate(0deg)',
@@ -178,29 +152,9 @@ const Dropdown = ({
178
152
  popover={popover}
179
153
  panel={false}
180
154
  focus={false}
181
- style={{
182
- backgroundColor: variant === 'minimal'
183
- ? 'var(--kol-surface-primary)'
184
- : styles.backgroundColor,
185
- color: 'var(--kol-surface-on-primary)',
186
- border: variant === 'subtle' ? 'none' : styles.border,
187
- borderRadius: variant === 'minimal'
188
- ? '0'
189
- : (variant === 'subtle'
190
- ? '0 0 4px 4px'
191
- : `0 0 ${metrics.panelRadius}px ${metrics.panelRadius}px`),
192
- }}
155
+ className={`kol-dd-panel kol-dd-panel--${resolvedVariant}`}
193
156
  >
194
- {variant !== 'minimal' && (
195
- <div style={{ padding: `0 ${metrics.paddingX}px` }}>
196
- <div
197
- style={{
198
- height: '1px',
199
- backgroundColor: 'var(--kol-border-default)'
200
- }}
201
- />
202
- </div>
203
- )}
157
+ {resolvedVariant === 'primary' && <div className="kol-dd-div" />}
204
158
 
205
159
  <div className="flex max-h-[300px] flex-col items-stretch overflow-y-auto" role="listbox">
206
160
  {options.map((option) => {
@@ -4,11 +4,9 @@ import Input from '../atoms/Input.jsx'
4
4
  /**
5
5
  * Slider — range slider with label and an editable value readout.
6
6
  *
7
- * Variants (track/shell styling):
8
- * default bordered track + boxed value.
9
- * minimal bare track + boxed value. 99% of real usage (foundry previews,
10
- * inline controls). PRESERVED from web.
11
- * subtle — filled rounded chip; for inspector-style controls.
7
+ * One bare inline row: label · track · editable readout. (Bordered
8
+ * `default` and chip `subtle` variants retired 2026-07-08 — minimal is
9
+ * the only slider.)
12
10
  *
13
11
  * The value readout is an editable <Input> (type a value, commit on
14
12
  * blur / Enter, revert on Escape). Track color is exposed as the
@@ -21,7 +19,6 @@ import Input from '../atoms/Input.jsx'
21
19
  * @param {number} props.max - Maximum value
22
20
  * @param {number} props.value - Current value
23
21
  * @param {Function} props.onChange - Change handler
24
- * @param {'default'|'minimal'|'subtle'} props.variant - Visual variant (default: 'default')
25
22
  * @param {string} props.className - Additional wrapper classes
26
23
  * @param {number} props.displayWidth - Width of the value readout, in characters (default: 6)
27
24
  * @param {string} props.fontSize - Font size for label/value (e.g., '11px')
@@ -34,7 +31,6 @@ const Slider = ({
34
31
  max = 100,
35
32
  value = 0,
36
33
  onChange,
37
- variant = 'default',
38
34
  className = '',
39
35
  displayWidth = 6,
40
36
  fontSize,
@@ -47,12 +43,6 @@ const Slider = ({
47
43
  }
48
44
  }
49
45
 
50
- const variantClass = variant === 'minimal'
51
- ? 'control-slider-minimal'
52
- : variant === 'subtle'
53
- ? 'control-slider-subtle'
54
- : 'control-slider'
55
-
56
46
  const decimals = useMemo(() => {
57
47
  if (formatValue) return null
58
48
  if (!Number.isFinite(step)) return 0
@@ -94,7 +84,7 @@ const Slider = ({
94
84
  }
95
85
 
96
86
  return (
97
- <div className={`${variantClass} gap-3 shadow-none ${className}`}>
87
+ <div className={`control-slider gap-3 shadow-none ${className}`}>
98
88
  {label && (
99
89
  <label className="kol-helper-12 whitespace-nowrap shrink-0 w-fit" style={fontSize ? { fontSize } : undefined}>
100
90
  {label}