@kolkrabbi/kol-component 0.35.0 → 0.37.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.35.0",
3
+ "version": "0.37.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",
@@ -29,7 +29,7 @@
29
29
  "@floating-ui/react": "^0.27.19",
30
30
  "embla-carousel-react": "^8.6.0",
31
31
  "react-syntax-highlighter": "^16.1.1",
32
- "@kolkrabbi/kol-icons": "^0.14.0"
32
+ "@kolkrabbi/kol-icons": "^0.15.0"
33
33
  },
34
34
  "peerDependencies": {
35
35
  "framer-motion": "^12.0.0",
@@ -9,6 +9,14 @@ import { glyphSize } from '../hooks/glyphLadders.js'
9
9
  * treatment (2026-07-08 chrome law: one
10
10
  * secondary, always subordinate to filled)
11
11
  * variant="ghost" — legacy alias, resolves to outline
12
+ * variant="property" — the Figma property field (PropertyField,
13
+ * 2026-08-12): filled chrome, dim `affordance`
14
+ * (letter or icon node) at a 6px gap, the value
15
+ * HUGS its own length (mono ch-width — number-
16
+ * safe, no `size` attr), and `unit` renders
17
+ * IMMEDIATELY after the value (`0°`, `100%`).
18
+ * Shell fills its cell, content left-packed.
19
+ * Width tracks `value` — controlled usage only.
12
20
  *
13
21
  * size="sm" / "md" (default) / "lg" — matched padding + type class
14
22
  *
@@ -47,6 +55,8 @@ export default function Input({
47
55
  prefix,
48
56
  suffix,
49
57
  slotLeft,
58
+ affordance,
59
+ unit,
50
60
  iconLeft,
51
61
  iconSize = null,
52
62
  placeholder,
@@ -66,7 +76,9 @@ export default function Input({
66
76
  const resolvedIconSize = iconSize ?? glyphSize(size)
67
77
 
68
78
  // ghost folds into outline (2026-07-08 chrome law): one secondary treatment.
69
- const resolvedVariant = variant === 'ghost' ? 'outline' : variant
79
+ // property rides the filled chrome it is a behaviour variant, not new paint.
80
+ const isProperty = variant === 'property'
81
+ const resolvedVariant = variant === 'ghost' ? 'outline' : isProperty ? 'filled' : variant
70
82
 
71
83
  const shellCls = [
72
84
  'kol-control',
@@ -74,9 +86,16 @@ export default function Input({
74
86
  `kol-control-${size}`,
75
87
  SIZE_TYPE[size],
76
88
  'cursor-text',
89
+ isProperty && 'w-full',
77
90
  className,
78
91
  ].filter(Boolean).join(' ')
79
92
 
93
+ /* Property width: the shell type is mono, so every glyph is exactly 1ch —
94
+ * `${len}ch` hugs the value with no probe element, and stays number-safe
95
+ * where the HTML `size` attr is ignored (<input type="number">). +2px keeps
96
+ * the caret from clipping at the end. Tracks `value` → controlled only. */
97
+ const propertyLen = Math.max(String(value ?? placeholder ?? '').length, 1)
98
+
80
99
  /* Pin inner input height to the typography token's line-height. Without
81
100
  * this the `<input>` renders ~0.5px taller than the equivalent <button>
82
101
  * or <label> at the same kol-mono-N — Chromium computes input height
@@ -88,7 +107,7 @@ export default function Input({
88
107
  const inputCls = [
89
108
  'min-w-0 bg-transparent border-none outline-none text-auto',
90
109
  heightCls,
91
- !fixedChars && 'flex-1',
110
+ !fixedChars && !isProperty && 'flex-1',
92
111
  /* Balance the dim prefix/suffix visual weight with extra inner padding
93
112
  * on the opposite side. Without this the bright value sits closer to
94
113
  * the affordance than to the empty edge, reads off-balance. */
@@ -115,6 +134,9 @@ export default function Input({
115
134
  {prefix !== undefined && (
116
135
  <span aria-hidden="true" className="text-meta pr-1 shrink-0">{prefix}</span>
117
136
  )}
137
+ {affordance !== undefined && (
138
+ <span aria-hidden="true" className="text-meta pr-1.5 shrink-0 inline-flex items-center">{affordance}</span>
139
+ )}
118
140
  {/* Controlled only when a `value` prop is passed — otherwise stay
119
141
  * uncontrolled so prop-less usages (search stubs, quick demos) type
120
142
  * normally instead of freezing on a value-without-onChange input.
@@ -129,8 +151,12 @@ export default function Input({
129
151
  spellCheck={false}
130
152
  size={fixedChars ? chars : undefined}
131
153
  className={inputCls}
154
+ style={isProperty ? { width: `calc(${propertyLen}ch + 2px)` } : undefined}
132
155
  {...inputProps}
133
156
  />
157
+ {unit !== undefined && (
158
+ <span aria-hidden="true" className="text-meta shrink-0">{unit}</span>
159
+ )}
134
160
  {suffix !== undefined && (
135
161
  <span aria-hidden="true" className="text-meta pl-1 shrink-0">{suffix}</span>
136
162
  )}
@@ -23,9 +23,21 @@
23
23
  * the group on the active cell, ←/→ (or ↑/↓) move selection + focus.
24
24
  *
25
25
  * Props:
26
- * value — current option value
26
+ * value — current option value. `null`/`undefined` = STATELESS mode
27
+ * (the segmented state law, 2026-08-12): role `group`, no
28
+ * aria-checked, no selected styling — a pure one-shot ACTION
29
+ * strip (canvas alignment, transform cluster); onChange is the
30
+ * action dispatch.
27
31
  * onChange — handler (newValue) => void
28
32
  * options — [{ value, label, ariaLabel? }]
33
+ * variant — 'default' (shipped chrome: shared outer stroke + dividers,
34
+ * filled active cell) | 'filled' (the state law's tiles: every
35
+ * cell a surface-secondary tile with 1px transparent gaps, NO
36
+ * outline shell; the inset ring marks ONLY the selected cell) |
37
+ * 'tonal' (filled tiles, but the clicked cell marks itself by
38
+ * TONE — surface-tertiary fill, no ring; 2026-08-12).
39
+ * Every variant renders at the SAME pinned button-ladder
40
+ * height (26/32/40) — icon or text, the box never moves.
29
41
  * size — mirrors Button exactly: 'sm' (26px, mono-12, 4/12 pad) |
30
42
  * 'md' (default, 32px, mono-14, 6/16 pad) | 'lg' (40px,
31
43
  * mono-16, 8/20 pad). Same cell padding + mono type as the
@@ -34,11 +46,13 @@
34
46
  * ariaLabel — accessible name for the group
35
47
  * className — additional classes on the outer shell
36
48
  */
37
- export default function SegmentedToggle({ value, onChange, options = [], size = 'md', ariaLabel, className = '' }) {
49
+ export default function SegmentedToggle({ value, onChange, options = [], variant = 'default', size = 'md', ariaLabel, className = '' }) {
38
50
  const cellType = { sm: 'kol-mono-12', md: 'kol-mono-14', lg: 'kol-mono-16' }[size]
51
+ const stateless = value == null
39
52
  const focusIdx = Math.max(0, options.findIndex((opt) => opt.value === value))
40
53
 
41
54
  const handleKeyDown = (e) => {
55
+ if (stateless) return // plain button row — Tab moves focus, arrows do nothing
42
56
  const dir = { ArrowLeft: -1, ArrowUp: -1, ArrowRight: 1, ArrowDown: 1 }[e.key]
43
57
  if (!dir || !options.length) return
44
58
  e.preventDefault()
@@ -49,21 +63,27 @@ export default function SegmentedToggle({ value, onChange, options = [], size =
49
63
 
50
64
  return (
51
65
  <div
52
- role="radiogroup"
66
+ role={stateless ? 'group' : 'radiogroup'}
53
67
  aria-label={ariaLabel}
54
68
  onKeyDown={handleKeyDown}
55
- className={['kol-seg', size !== 'md' && `kol-seg--${size}`, className].filter(Boolean).join(' ')}
69
+ className={[
70
+ 'kol-seg',
71
+ (variant === 'filled' || variant === 'tonal') && 'kol-seg--filled',
72
+ variant === 'tonal' && 'kol-seg--tonal',
73
+ size !== 'md' && `kol-seg--${size}`,
74
+ className,
75
+ ].filter(Boolean).join(' ')}
56
76
  >
57
77
  {options.map((opt, i) => {
58
- const isActive = opt.value === value
78
+ const isActive = !stateless && opt.value === value
59
79
  return (
60
80
  <button
61
81
  key={opt.value}
62
82
  type="button"
63
- role="radio"
64
- aria-checked={isActive}
83
+ role={stateless ? undefined : 'radio'}
84
+ aria-checked={stateless ? undefined : isActive}
65
85
  aria-label={opt.ariaLabel}
66
- tabIndex={i === focusIdx ? 0 : -1}
86
+ tabIndex={stateless ? 0 : (i === focusIdx ? 0 : -1)}
67
87
  onClick={() => onChange?.(opt.value)}
68
88
  className={['kol-seg-cell', cellType, isActive && 'is-active'].filter(Boolean).join(' ')}
69
89
  >