@keyfunc/keybricks-ds 1.0.3 → 1.0.4

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.
@@ -0,0 +1,67 @@
1
+ import React from "react";
2
+ import { Icon } from "../icons/Icon.jsx";
3
+
4
+ const radioDims = { sm: { box: 13, dot: 6, font: "var(--text-xs)" }, md: { box: 16, dot: 8, font: "var(--text-sm)" } };
5
+
6
+ /**
7
+ * Radio — 16px (sm: 13px) circle, 1.5px border, filled action-blue dot when selected.
8
+ * hover darkens the ring; keyboard focus shows the 2px focus ring; disabled = 50% opacity.
9
+ * description renders a muted second line under the label (settings-style choices).
10
+ */
11
+ export function Radio({ checked, onChange, label, description, size = "md", disabled, style }) {
12
+ const d = radioDims[size] || radioDims.md;
13
+ const [hover, setHover] = React.useState(false);
14
+ const [focus, setFocus] = React.useState(false);
15
+ const ring = checked ? "var(--action-primary)" : hover && !disabled ? "var(--text-muted)" : "var(--checkbox-border)";
16
+ return (
17
+ <label role="radio" aria-checked={!!checked} aria-disabled={disabled || undefined} tabIndex={disabled ? -1 : 0}
18
+ onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)} onFocus={() => setFocus(true)} onBlur={() => setFocus(false)}
19
+ onKeyDown={(e) => { if (e.key === " " || e.key === "Enter") { e.preventDefault(); !disabled && onChange && onChange(true); } }}
20
+ style={{ display: "inline-flex", alignItems: description ? "flex-start" : "center", verticalAlign: "middle", lineHeight: "var(--leading-sm)", gap: 8, cursor: disabled ? "not-allowed" : "pointer", opacity: disabled ? .5 : 1, outline: "none", ...style }}
21
+ onClick={() => !disabled && onChange && onChange(true)}>
22
+ <span style={{ width: d.box, height: d.box, marginTop: description ? 1 : 0, borderRadius: "var(--radius-full)", boxSizing: "border-box", border: `1.5px solid ${ring}`, background: "var(--bg-surface)", boxShadow: focus && !disabled ? "0 0 0 2px var(--focus-ring)" : "none", display: "inline-flex", alignItems: "center", justifyContent: "center", flexShrink: 0, transition: "var(--t-hover)" }}>
23
+ {checked ? <span style={{ width: d.dot, height: d.dot, borderRadius: "var(--radius-full)", background: "var(--action-primary)" }}></span> : null}
24
+ </span>
25
+ {label || description ? <span style={{ display: "flex", flexDirection: "column", gap: 2 }}>
26
+ {label ? <span style={{ fontFamily: "var(--font-sans)", fontWeight: "var(--weight-medium)", fontSize: d.font, lineHeight: "var(--leading-sm)", color: "var(--text-body)" }}>{label}</span> : null}
27
+ {description ? <span style={{ fontFamily: "var(--font-sans)", fontSize: "var(--text-xs)", lineHeight: "16px", color: "var(--text-muted)" }}>{description}</span> : null}
28
+ </span> : null}
29
+ </label>
30
+ );
31
+ }
32
+
33
+ /**
34
+ * RadioGroup — single choice from a small set. Vertical (default) or `row`.
35
+ * options: string[] | { label, value, description?, disabled? }[]
36
+ * Arrow keys move the selection between enabled options (WAI-ARIA radiogroup).
37
+ * variant="cards" renders each option as a bordered selectable card (plan pickers, migration strategies).
38
+ */
39
+ export function RadioGroup({ options = [], value, onChange, row, size = "md", variant = "plain", disabled, style }) {
40
+ const opts = options.map((o) => (typeof o === "string" ? { label: o, value: o } : o));
41
+ const enabled = opts.filter((o) => !o.disabled && !disabled);
42
+ const onKey = (e) => {
43
+ if (!["ArrowDown", "ArrowRight", "ArrowUp", "ArrowLeft"].includes(e.key) || !enabled.length) return;
44
+ e.preventDefault();
45
+ const i = enabled.findIndex((o) => o.value === value);
46
+ const next = e.key === "ArrowDown" || e.key === "ArrowRight" ? enabled[(i + 1) % enabled.length] : enabled[(i - 1 + enabled.length) % enabled.length];
47
+ onChange && onChange(next.value);
48
+ };
49
+ if (variant === "cards") {
50
+ return (
51
+ <div role="radiogroup" onKeyDown={onKey} style={{ display: "flex", flexDirection: row ? "row" : "column", gap: 8, ...style }}>
52
+ {opts.map((o) => {
53
+ const on = value === o.value, off = o.disabled || disabled;
54
+ return <div key={o.value} onClick={() => !off && onChange && onChange(o.value)}
55
+ style={{ flex: row ? 1 : "none", padding: "10px 12px", borderRadius: "var(--radius-md)", border: `1px solid ${on ? "var(--action-primary)" : "var(--border-card)"}`, background: on ? "var(--soft-bg)" : "var(--bg-surface)", cursor: off ? "not-allowed" : "pointer", opacity: off ? .5 : 1, transition: "var(--t-hover)" }}>
56
+ <Radio checked={on} onChange={() => onChange && onChange(o.value)} label={o.label} description={o.description} size={size} disabled={off} style={{ opacity: 1, cursor: "inherit" }} />
57
+ </div>;
58
+ })}
59
+ </div>
60
+ );
61
+ }
62
+ return (
63
+ <div role="radiogroup" onKeyDown={onKey} style={{ display: "flex", flexDirection: row ? "row" : "column", gap: row ? 16 : 10, ...style }}>
64
+ {opts.map((o) => <Radio key={o.value} label={o.label} description={o.description} size={size} disabled={o.disabled || disabled} checked={value === o.value} onChange={() => onChange && onChange(o.value)} />)}
65
+ </div>
66
+ );
67
+ }
@@ -1,16 +1,25 @@
1
1
  <script setup>
2
- import { computed } from 'vue'
3
- // Radio — 16px (sm: 13px) circle, 1.5px border, filled dot when selected.
4
- const dims = { sm: { box: 13, dot: 6 }, md: { box: 16, dot: 8 } }
5
- const props = defineProps({ checked: Boolean, label: String, size: { type: String, default: 'md' }, disabled: Boolean, style: Object })
2
+ import { ref, computed } from 'vue'
3
+ // Radio — 16px (sm: 13px) circle, 1.5px border, filled dot when selected. Hover darkens the ring, focus shows the 2px ring, disabled = 50% opacity.
4
+ // description renders a muted second line under the label.
5
+ const dims = { sm: { box: 13, dot: 6, font: 'var(--text-xs)' }, md: { box: 16, dot: 8, font: 'var(--text-sm)' } }
6
+ const props = defineProps({ checked: Boolean, label: String, description: String, size: { type: String, default: 'md' }, disabled: Boolean, style: Object })
6
7
  const emit = defineEmits(['change'])
7
8
  const d = computed(() => dims[props.size] || dims.md)
9
+ const hover = ref(false), focus = ref(false)
10
+ const ring = computed(() => props.checked ? 'var(--action-primary)' : hover.value && !props.disabled ? 'var(--text-muted)' : 'var(--checkbox-border)')
11
+ const pick = () => { if (!props.disabled) emit('change', true) }
8
12
  </script>
9
13
  <template>
10
- <label :style="{ display: 'inline-flex', alignItems: 'center', verticalAlign: 'middle', lineHeight: 'var(--leading-sm)', gap: 6, cursor: disabled ? 'default' : 'pointer', opacity: disabled ? .5 : 1, ...style }" @click="!disabled && emit('change', true)">
11
- <span :style="{ width: d.box + 'px', height: d.box + 'px', borderRadius: 'var(--radius-full)', boxSizing: 'border-box', border: `1.5px solid ${checked ? 'var(--action-primary)' : 'var(--checkbox-border)'}`, background: 'var(--bg-surface)', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, transition: 'var(--t-hover)' }">
12
- <span v-if="checked" :style="{ width: d.dot + 'px', height: d.dot + 'px', borderRadius: 'var(--radius-full)', background: 'var(--action-primary)' }" />
14
+ <label role="radio" :aria-checked="checked" :aria-disabled="disabled || undefined" :tabindex="disabled ? -1 : 0"
15
+ @mouseenter="hover = true" @mouseleave="hover = false" @focus="focus = true" @blur="focus = false" @click="pick" @keydown.space.prevent="pick" @keydown.enter.prevent="pick"
16
+ :style="{ display: 'inline-flex', alignItems: description ? 'flex-start' : 'center', verticalAlign: 'middle', lineHeight: 'var(--leading-sm)', gap: '8px', cursor: disabled ? 'not-allowed' : 'pointer', opacity: disabled ? .5 : 1, outline: 'none', ...style }">
17
+ <span :style="{ width: d.box + 'px', height: d.box + 'px', marginTop: description ? '1px' : 0, borderRadius: 'var(--radius-full)', boxSizing: 'border-box', border: `1.5px solid ${ring}`, background: 'var(--bg-surface)', boxShadow: focus && !disabled ? '0 0 0 2px var(--focus-ring)' : 'none', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, transition: 'var(--t-hover)' }">
18
+ <span v-if="checked" :style="{ width: d.dot + 'px', height: d.dot + 'px', borderRadius: 'var(--radius-full)', background: 'var(--action-primary)' }"></span>
19
+ </span>
20
+ <span v-if="label || description" style="display:flex;flex-direction:column;gap:2px">
21
+ <span v-if="label" :style="{ fontFamily: 'var(--font-sans)', fontWeight: 'var(--weight-medium)', fontSize: d.font, lineHeight: 'var(--leading-sm)', color: 'var(--text-body)' }">{{ label }}</span>
22
+ <span v-if="description" style="font-family:var(--font-sans);font-size:var(--text-xs);line-height:16px;color:var(--text-muted)">{{ description }}</span>
13
23
  </span>
14
- <span v-if="label" :style="{ fontFamily: 'var(--font-sans)', fontWeight: 'var(--weight-medium)', fontSize: size === 'sm' ? 'var(--text-xs)' : 'var(--text-sm)', lineHeight: 'var(--leading-sm)', color: 'var(--text-body)' }">{{ label }}</span>
15
24
  </label>
16
25
  </template>
@@ -1,11 +1,29 @@
1
1
  <script setup>
2
+ import { computed } from 'vue'
2
3
  import Radio from './Radio.vue'
3
- // Vertical or horizontal group of Radios.
4
- defineProps({ options: { type: Array, default: () => [] }, modelValue: String, row: Boolean, size: { type: String, default: 'md' }, style: Object })
4
+ // RadioGroup — single choice. Vertical (default) or `row`. options: string[] | { label, value, description?, disabled? }[].
5
+ // Arrow keys move between enabled options. variant="cards" renders bordered selectable cards. v-model.
6
+ const props = defineProps({ options: { type: Array, default: () => [] }, modelValue: String, row: Boolean, size: { type: String, default: 'md' }, variant: { type: String, default: 'plain' }, disabled: Boolean, style: Object })
5
7
  const emit = defineEmits(['update:modelValue'])
8
+ const opts = computed(() => props.options.map(o => typeof o === 'string' ? { label: o, value: o } : o))
9
+ const enabled = computed(() => opts.value.filter(o => !o.disabled && !props.disabled))
10
+ function onKey(e) {
11
+ if (!['ArrowDown', 'ArrowRight', 'ArrowUp', 'ArrowLeft'].includes(e.key) || !enabled.value.length) return
12
+ e.preventDefault()
13
+ const i = enabled.value.findIndex(o => o.value === props.modelValue), n = enabled.value.length
14
+ const next = (e.key === 'ArrowDown' || e.key === 'ArrowRight') ? enabled.value[(i + 1) % n] : enabled.value[(i - 1 + n) % n]
15
+ emit('update:modelValue', next.value)
16
+ }
17
+ const pick = (o) => { if (!(o.disabled || props.disabled)) emit('update:modelValue', o.value) }
6
18
  </script>
7
19
  <template>
8
- <div :style="{ display: 'flex', flexDirection: row ? 'row' : 'column', gap: (row ? 16 : 10) + 'px', ...style }">
9
- <Radio v-for="o in options" :key="o" :label="o" :size="size" :checked="modelValue === o" @change="emit('update:modelValue', o)" />
20
+ <div v-if="variant === 'cards'" role="radiogroup" @keydown="onKey" :style="{ display: 'flex', flexDirection: row ? 'row' : 'column', gap: '8px', ...style }">
21
+ <div v-for="o in opts" :key="o.value" @click="pick(o)"
22
+ :style="{ flex: row ? 1 : 'none', padding: '10px 12px', borderRadius: 'var(--radius-md)', border: `1px solid ${modelValue === o.value ? 'var(--action-primary)' : 'var(--border-card)'}`, background: modelValue === o.value ? 'var(--soft-bg)' : 'var(--bg-surface)', cursor: (o.disabled || disabled) ? 'not-allowed' : 'pointer', opacity: (o.disabled || disabled) ? .5 : 1, transition: 'var(--t-hover)' }">
23
+ <Radio :checked="modelValue === o.value" :label="o.label" :description="o.description" :size="size" :disabled="o.disabled || disabled" :style="{ opacity: 1, cursor: 'inherit' }" @change="pick(o)" />
24
+ </div>
25
+ </div>
26
+ <div v-else role="radiogroup" @keydown="onKey" :style="{ display: 'flex', flexDirection: row ? 'row' : 'column', gap: (row ? 16 : 10) + 'px', ...style }">
27
+ <Radio v-for="o in opts" :key="o.value" :label="o.label" :description="o.description" :size="size" :disabled="o.disabled || disabled" :checked="modelValue === o.value" @change="pick(o)" />
10
28
  </div>
11
29
  </template>
@@ -1,31 +1,39 @@
1
1
  import * as React from "react";
2
- /** 34×20 switch for settings-style booleans. Addition — not in the source file. */
2
+ /**
3
+ * Switch — 36×20 pill (lg 44×24), 16/20px knob, 160ms ease-out slide. Controlled.
4
+ * Variants: knob glyph (icon or one character), text inside the pill, captions beside it,
5
+ * a semantic red→green tone, and an async gate that shows a spinner in the knob.
6
+ */
3
7
  export interface SwitchProps {
4
8
  checked?: boolean;
5
- onChange?: (checked: boolean) => void;
6
- label?: string;
7
- size?: "md" | "sm";
9
+ onChange?(next: boolean): void;
8
10
  disabled?: boolean;
9
- style?: React.CSSProperties;
10
- }
11
- export declare function Switch(props: SwitchProps): JSX.Element;
12
- /** 16px (sm: 13px) radio. Addition — not in the source file. */
13
- export interface RadioProps {
14
- checked?: boolean;
15
- onChange?: (checked: boolean) => void;
11
+ /** sm 28×16 · md 36×20 (default) · lg 44×24 — use lg whenever the knob carries a glyph */
12
+ size?: "sm" | "md" | "lg";
13
+ /** primary = action blue on / border-strong off · semantic = success on / danger off */
14
+ tone?: "primary" | "semantic";
15
+ /** Explicit track colors; override tone. Prefer tokens. */
16
+ onColor?: string;
17
+ offColor?: string;
18
+ /** Icon inside the knob, or a pair for each state */
19
+ knobIcon?: string | { on: string; off: string };
20
+ /** ONE letter or ONE CJK character inside the knob, or a pair */
21
+ knobText?: string | { on: string; off: string };
22
+ /** Text inside the pill opposite the knob — keep it to 1–3 characters */
23
+ insideOn?: string;
24
+ insideOff?: string;
25
+ /** Alias for the insideOn/insideOff pair */
26
+ inlinePrompt?: { on: string; off: string };
27
+ /** Single caption to the right (settings rows) — text-body, state-independent */
16
28
  label?: string;
17
- size?: "md" | "sm";
18
- disabled?: boolean;
19
- style?: React.CSSProperties;
20
- }
21
- export declare function Radio(props: RadioProps): JSX.Element;
22
- /** Radio set; `row` lays them horizontally. */
23
- export interface RadioGroupProps {
24
- options?: string[];
25
- value?: string;
26
- onChange?: (value: string) => void;
27
- row?: boolean;
28
- size?: "md" | "sm";
29
+ /** Captions beside the pill; the active side takes text-heading */
30
+ labelOn?: string;
31
+ labelOff?: string;
32
+ /** Controlled loading: spinner in the knob, input inert */
33
+ loading?: boolean;
34
+ /** Async gate — Switch shows loading until it settles; flips only on truthy */
35
+ beforeChange?(next: boolean): Promise<boolean> | boolean;
29
36
  style?: React.CSSProperties;
30
37
  }
31
- export declare function RadioGroup(props: RadioGroupProps): JSX.Element;
38
+ export declare function Switch(props: SwitchProps): JSX.Element;
39
+ export default Switch;
@@ -1,40 +1,92 @@
1
1
  import React from "react";
2
+ import { Icon } from "../icons/Icon.jsx";
2
3
 
3
- const switchDims = { sm: { w: 28, h: 16, knob: 12 }, md: { w: 34, h: 20, knob: 16 } };
4
- /** Switch — 34×20 (sm: 28×16) track, knob. Keybricks has no switch in the source file; this follows the button radii + action color. */
5
- export function Switch({ checked, onChange, label, size = "md", disabled, style }) {
6
- const d = switchDims[size] || switchDims.md;
7
- return (
8
- <label style={{ display: "inline-flex", alignItems: "center", verticalAlign: "middle", lineHeight: "var(--leading-sm)", gap: 8, cursor: disabled ? "default" : "pointer", opacity: disabled ? .5 : 1, ...style }}
9
- onClick={() => !disabled && onChange && onChange(!checked)}>
10
- <span style={{ width: d.w, height: d.h, borderRadius: "var(--radius-full)", background: checked ? "var(--action-primary)" : "var(--track)", position: "relative", flexShrink: 0, transition: "var(--t-hover)" }}>
11
- <span style={{ position: "absolute", top: 2, left: checked ? d.w - d.knob - 2 : 2, width: d.knob, height: d.knob, borderRadius: "var(--radius-full)", background: "var(--on-accent)", boxShadow: "0 1px 2px rgba(0,0,0,.2)", transition: "left var(--dur-fast) var(--ease-out)" }} />
12
- </span>
13
- {label ? <span style={{ fontFamily: "var(--font-sans)", fontWeight: "var(--weight-medium)", fontSize: size === "sm" ? "var(--text-xs)" : "var(--text-sm)", lineHeight: "var(--leading-sm)", color: "var(--text-body)" }}>{label}</span> : null}
14
- </label>
15
- );
16
- }
4
+ /**
5
+ * Switch — 36×20 pill, 16px knob, 160ms ease-out slide.
6
+ *
7
+ * checked / onChange(next) controlled boolean
8
+ * disabled 50% opacity, no pointer
9
+ * size "md" (36×20, default) | "lg" (44×24 — needed for knobIcon / knobText)
10
+ * tone "primary" (default, action blue) | "semantic" (red off → green on)
11
+ * onColor / offColor explicit track colors; override tone
12
+ * knobIcon / knobText glyph inside the knob: string | { on, off }. knobText is ONE letter or ONE CJK character.
13
+ * insideOn / insideOff text inside the pill on the far side of the knob (short — "ON"/"OFF", "开"/"关", "Y"/"N")
14
+ * labelOn / labelOff captions beside the pill; active caption takes text-heading, inactive takes text-muted
15
+ * loading controlled: knob shows a spinner, input is inert
16
+ * beforeChange(next) => Promise|boolean
17
+ * async gate — Switch enters loading, flips only if the promise resolves truthy
18
+ * inlinePrompt (Vue-friendly alias of insideOn/insideOff pair {on,off})
19
+ */
17
20
 
18
- const radioDims = { sm: { box: 13, dot: 6 }, md: { box: 16, dot: 8 } };
19
- /** Radio — 16px (sm: 13px) circle, 1.5px border, filled dot when selected. */
20
- export function Radio({ checked, onChange, label, size = "md", disabled, style }) {
21
- const d = radioDims[size] || radioDims.md;
22
- return (
23
- <label style={{ display: "inline-flex", alignItems: "center", verticalAlign: "middle", lineHeight: "var(--leading-sm)", gap: 6, cursor: disabled ? "default" : "pointer", opacity: disabled ? .5 : 1, ...style }}
24
- onClick={() => !disabled && onChange && onChange(true)}>
25
- <span style={{ width: d.box, height: d.box, borderRadius: "var(--radius-full)", boxSizing: "border-box", border: `1.5px solid ${checked ? "var(--action-primary)" : "var(--checkbox-border)"}`, background: "var(--bg-surface)", display: "inline-flex", alignItems: "center", justifyContent: "center", flexShrink: 0, transition: "var(--t-hover)" }}>
26
- {checked ? <span style={{ width: d.dot, height: d.dot, borderRadius: "var(--radius-full)", background: "var(--action-primary)" }} /> : null}
27
- </span>
28
- {label ? <span style={{ fontFamily: "var(--font-sans)", fontWeight: "var(--weight-medium)", fontSize: size === "sm" ? "var(--text-xs)" : "var(--text-sm)", lineHeight: "var(--leading-sm)", color: "var(--text-body)" }}>{label}</span> : null}
29
- </label>
30
- );
31
- }
21
+ const SIZES = {
22
+ sm: { w: 28, h: 16, knob: 12, pad: 2, inside: 9, glyph: 8, label: "var(--text-xs)" },
23
+ md: { w: 36, h: 20, knob: 16, pad: 2, inside: 10, glyph: 10, label: "var(--text-sm)" },
24
+ lg: { w: 44, h: 24, knob: 20, pad: 2, inside: 11, glyph: 12, label: "var(--text-sm)" }
25
+ };
26
+
27
+ const pick = (v, on) => (v && typeof v === "object" ? (on ? v.on : v.off) : v);
28
+
29
+ export function Switch({ checked = false, onChange, disabled, size = "md", tone = "primary", onColor, offColor, knobIcon, knobText, insideOn, insideOff, inlinePrompt, label, labelOn, labelOff, loading: loadingProp, beforeChange, style }) {
30
+ // `label` = single caption to the right, shown in text-body regardless of state (settings-row usage)
31
+ const single = label != null && labelOn == null && labelOff == null;
32
+ const [busy, setBusy] = React.useState(false);
33
+ const alive = React.useRef(true);
34
+ React.useEffect(() => () => { alive.current = false; }, []);
35
+ const loading = loadingProp || busy;
36
+ const s = SIZES[size] || SIZES.md;
37
+ if (inlinePrompt) { insideOn = insideOn ?? inlinePrompt.on; insideOff = insideOff ?? inlinePrompt.off; }
38
+ const hasInside = insideOn != null || insideOff != null;
39
+ // inside text needs a wider track so the knob never covers the word
40
+ const insideExtra = hasInside ? Math.max(0, Math.max(String(insideOn ?? "").length, String(insideOff ?? "").length) * (s.inside * .68) + 6 - (s.w - s.knob - s.pad * 2)) : 0;
41
+ const w = s.w + insideExtra;
42
+
43
+ const onTrack = onColor || (tone === "semantic" ? "var(--success)" : "var(--action-primary)");
44
+ const offTrack = offColor || (tone === "semantic" ? "var(--danger)" : "var(--border-strong)");
45
+ const track = checked ? onTrack : offTrack;
46
+ const inert = disabled || loading;
47
+
48
+ const flip = async () => {
49
+ if (inert) return;
50
+ const next = !checked;
51
+ if (beforeChange) {
52
+ setBusy(true);
53
+ let ok = false;
54
+ try { ok = await beforeChange(next); } catch (e) { ok = false; }
55
+ if (!alive.current) return;
56
+ setBusy(false);
57
+ if (!ok) return;
58
+ }
59
+ onChange && onChange(next);
60
+ };
32
61
 
33
- /** Vertical or horizontal group of Radios. */
34
- export function RadioGroup({ options = [], value, onChange, row, size = "md", style }) {
35
- return (
36
- <div style={{ display: "flex", flexDirection: row ? "row" : "column", gap: row ? 16 : 10, ...style }}>
37
- {options.map((o) => <Radio key={o} label={o} size={size} checked={value === o} onChange={() => onChange && onChange(o)} />)}
38
- </div>
39
- );
62
+ const glyphIcon = pick(knobIcon, checked);
63
+ const glyphText = pick(knobText, checked);
64
+ const knobX = checked ? w - s.knob - s.pad : s.pad;
65
+
66
+ const knob = <span aria-hidden="true" style={{ position: "absolute", top: s.pad, left: knobX, width: s.knob, height: s.knob, borderRadius: "var(--radius-full)", background: "var(--bg-surface)", boxShadow: "0 1px 2px rgba(0,0,0,.18)", transition: "left 160ms ease-out", display: "flex", alignItems: "center", justifyContent: "center", color: track, fontSize: s.glyph, lineHeight: 1, fontWeight: 600, fontFamily: "var(--font-sans)", overflow: "hidden" }}>
67
+ {loading
68
+ ? <span style={{ width: s.glyph, height: s.glyph, borderRadius: "50%", border: `1.5px solid ${track}`, borderRightColor: "transparent", animation: "kb-spin 720ms linear infinite" }}></span>
69
+ : glyphIcon ? <Icon name={glyphIcon} size={s.glyph} strokeWidth={2.5} />
70
+ : glyphText ? <span>{String(glyphText).slice(0, 1)}</span> : null}
71
+ </span>;
72
+
73
+ const insideStyle = (side) => ({ position: "absolute", top: 0, bottom: 0, [side]: s.pad + 4, display: "flex", alignItems: "center", fontSize: s.inside, lineHeight: 1, fontWeight: 600, fontFamily: "var(--font-sans)", letterSpacing: ".2px", color: "var(--on-accent)", transition: "opacity 160ms ease-out", pointerEvents: "none", whiteSpace: "nowrap" });
74
+
75
+ const pill = <span role="switch" aria-checked={checked} aria-busy={loading || undefined} aria-disabled={disabled || undefined} tabIndex={inert ? -1 : 0} onClick={flip}
76
+ onKeyDown={e => { if (e.key === " " || e.key === "Enter") { e.preventDefault(); flip(); } }}
77
+ style={{ position: "relative", display: "inline-block", width: w, height: s.h, borderRadius: "var(--radius-full)", background: track, transition: "background 160ms ease-out", cursor: inert ? (loading ? "progress" : "not-allowed") : "pointer", flexShrink: 0, verticalAlign: "middle", outline: "none" }}>
78
+ {hasInside && <span style={{ ...insideStyle("left"), opacity: checked ? 1 : 0 }}>{insideOn}</span>}
79
+ {hasInside && <span style={{ ...insideStyle("right"), opacity: checked ? 0 : 1 }}>{insideOff}</span>}
80
+ {knob}
81
+ </span>;
82
+
83
+ if (single) return <span style={{ display: "inline-flex", alignItems: "center", gap: 8, opacity: disabled ? .5 : 1, ...style }}>{pill}<span onClick={flip} style={{ fontSize: s.label, lineHeight: 1, fontFamily: "var(--font-sans)", fontWeight: 500, color: "var(--text-body)", cursor: inert ? "default" : "pointer", userSelect: "none" }}>{label}</span></span>;
84
+ if (labelOn == null && labelOff == null) return <span style={{ display: "inline-flex", alignItems: "center", opacity: disabled ? .5 : 1, ...style }}>{pill}</span>;
85
+
86
+ const cap = (text, active) => text == null ? null : <span style={{ fontSize: s.label, lineHeight: 1, fontFamily: "var(--font-sans)", fontWeight: active ? 500 : 400, color: active ? "var(--text-heading)" : "var(--text-muted)", transition: "color 160ms ease-out", cursor: inert ? "default" : "pointer", userSelect: "none" }} onClick={flip}>{text}</span>;
87
+ return <span style={{ display: "inline-flex", alignItems: "center", gap: 8, opacity: disabled ? .5 : 1, ...style }}>
88
+ {cap(labelOff, !checked)}{pill}{cap(labelOn, checked)}
89
+ </span>;
40
90
  }
91
+
92
+ export default Switch;
@@ -1,16 +1,61 @@
1
1
  <script setup>
2
- import { computed } from 'vue'
3
- // Switch — 34×20 (sm: 28×16) track, knob.
4
- const dims = { sm: { w: 28, h: 16, knob: 12 }, md: { w: 34, h: 20, knob: 16 } }
5
- const props = defineProps({ modelValue: Boolean, label: String, size: { type: String, default: 'md' }, disabled: Boolean, style: Object })
6
- const emit = defineEmits(['update:modelValue'])
7
- const d = computed(() => dims[props.size] || dims.md)
2
+ import { ref, computed, onBeforeUnmount } from 'vue'
3
+ import Icon from '../icons/Icon.vue'
4
+ // Switch — 36×20 pill (lg 44×24), 16/20px knob, 160ms slide. v-model:checked.
5
+ // tone "primary" | "semantic" (red off → green on); onColor/offColor override.
6
+ // knobIcon / knobText: string | { on, off } — knobText is ONE letter or ONE CJK char.
7
+ // insideOn / insideOff: text inside the pill opposite the knob. labelOn / labelOff: captions beside the pill.
8
+ // loading: controlled spinner. beforeChange(next) → Promise|boolean gates the flip and shows loading meanwhile.
9
+ const props = defineProps({
10
+ checked: Boolean, disabled: Boolean, loading: Boolean,
11
+ size: { type: String, default: 'md' }, tone: { type: String, default: 'primary' },
12
+ onColor: String, offColor: String,
13
+ knobIcon: [String, Object], knobText: [String, Object],
14
+ insideOn: String, insideOff: String, label: String, labelOn: String, labelOff: String,
15
+ beforeChange: Function, style: Object
16
+ })
17
+ const emit = defineEmits(['update:checked', 'change'])
18
+ const SIZES = { sm: { w: 28, h: 16, knob: 12, pad: 2, inside: 9, glyph: 8 }, md: { w: 36, h: 20, knob: 16, pad: 2, inside: 10, glyph: 10 }, lg: { w: 44, h: 24, knob: 20, pad: 2, inside: 11, glyph: 12 } }
19
+ const s = computed(() => SIZES[props.size] || SIZES.md)
20
+ const busy = ref(false); let alive = true; onBeforeUnmount(() => { alive = false })
21
+ const isLoading = computed(() => props.loading || busy.value)
22
+ const inert = computed(() => props.disabled || isLoading.value)
23
+ const hasInside = computed(() => props.insideOn != null || props.insideOff != null)
24
+ const width = computed(() => { if (!hasInside.value) return s.value.w; const n = Math.max((props.insideOn || '').length, (props.insideOff || '').length); return s.value.w + Math.max(0, n * (s.value.inside * .68) + 6 - (s.value.w - s.value.knob - s.value.pad * 2)) })
25
+ const track = computed(() => props.checked ? (props.onColor || (props.tone === 'semantic' ? 'var(--success)' : 'var(--action-primary)')) : (props.offColor || (props.tone === 'semantic' ? 'var(--danger)' : 'var(--border-strong)')))
26
+ const pick = (v) => v && typeof v === 'object' ? (props.checked ? v.on : v.off) : v
27
+ const glyphIcon = computed(() => pick(props.knobIcon))
28
+ const glyphText = computed(() => { const t = pick(props.knobText); return t ? String(t).slice(0, 1) : '' })
29
+ const knobX = computed(() => props.checked ? width.value - s.value.knob - s.value.pad : s.value.pad)
30
+ async function flip() {
31
+ if (inert.value) return
32
+ const next = !props.checked
33
+ if (props.beforeChange) {
34
+ busy.value = true; let ok = false
35
+ try { ok = await props.beforeChange(next) } catch (e) { ok = false }
36
+ if (!alive) return
37
+ busy.value = false
38
+ if (!ok) return
39
+ }
40
+ emit('update:checked', next); emit('change', next)
41
+ }
42
+ const insideStyle = (side, on) => ({ position: 'absolute', top: 0, bottom: 0, [side]: (s.value.pad + 4) + 'px', display: 'flex', alignItems: 'center', fontSize: s.value.inside + 'px', lineHeight: 1, fontWeight: 600, fontFamily: 'var(--font-sans)', letterSpacing: '.2px', color: 'var(--on-accent)', transition: 'opacity 160ms ease-out', pointerEvents: 'none', whiteSpace: 'nowrap', opacity: on === props.checked ? 1 : 0 })
43
+ const cap = (active) => ({ fontSize: props.size === 'sm' ? 'var(--text-xs)' : 'var(--text-sm)', lineHeight: 1, fontFamily: 'var(--font-sans)', fontWeight: active ? 500 : 400, color: active ? 'var(--text-heading)' : 'var(--text-muted)', transition: 'color 160ms ease-out', cursor: inert.value ? 'default' : 'pointer', userSelect: 'none' })
8
44
  </script>
9
45
  <template>
10
- <label :style="{ display: 'inline-flex', alignItems: 'center', verticalAlign: 'middle', lineHeight: 'var(--leading-sm)', gap: 8, cursor: disabled ? 'default' : 'pointer', opacity: disabled ? .5 : 1, ...style }" @click="!disabled && emit('update:modelValue', !modelValue)">
11
- <span :style="{ width: d.w + 'px', height: d.h + 'px', borderRadius: 'var(--radius-full)', background: modelValue ? 'var(--action-primary)' : 'var(--track)', position: 'relative', flexShrink: 0, transition: 'var(--t-hover)' }">
12
- <span :style="{ position: 'absolute', top: '2px', left: (modelValue ? d.w - d.knob - 2 : 2) + 'px', width: d.knob + 'px', height: d.knob + 'px', borderRadius: 'var(--radius-full)', background: 'var(--on-accent)', boxShadow: '0 1px 2px rgba(0,0,0,.2)', transition: 'left var(--dur-fast) var(--ease-out)' }" />
46
+ <span :style="{ display: 'inline-flex', alignItems: 'center', gap: '8px', opacity: disabled ? .5 : 1, ...style }">
47
+ <span v-if="labelOff != null" :style="cap(!checked)" @click="flip">{{ labelOff }}</span>
48
+ <span role="switch" :aria-checked="checked" :aria-busy="isLoading || undefined" :aria-disabled="disabled || undefined" :tabindex="inert ? -1 : 0" @click="flip" @keydown.space.prevent="flip" @keydown.enter.prevent="flip"
49
+ :style="{ position: 'relative', display: 'inline-block', width: width + 'px', height: s.h + 'px', borderRadius: 'var(--radius-full)', background: track, transition: 'background 160ms ease-out', cursor: inert ? (isLoading ? 'progress' : 'not-allowed') : 'pointer', flexShrink: 0, verticalAlign: 'middle', outline: 'none' }">
50
+ <span v-if="hasInside" :style="insideStyle('left', true)">{{ insideOn }}</span>
51
+ <span v-if="hasInside" :style="insideStyle('right', false)">{{ insideOff }}</span>
52
+ <span aria-hidden="true" :style="{ position: 'absolute', top: s.pad + 'px', left: knobX + 'px', width: s.knob + 'px', height: s.knob + 'px', borderRadius: 'var(--radius-full)', background: 'var(--bg-surface)', boxShadow: '0 1px 2px rgba(0,0,0,.18)', transition: 'left 160ms ease-out', display: 'flex', alignItems: 'center', justifyContent: 'center', color: track, fontSize: s.glyph + 'px', lineHeight: 1, fontWeight: 600, fontFamily: 'var(--font-sans)', overflow: 'hidden' }">
53
+ <span v-if="isLoading" :style="{ width: s.glyph + 'px', height: s.glyph + 'px', borderRadius: '50%', border: '1.5px solid ' + track, borderRightColor: 'transparent', animation: 'kb-spin 720ms linear infinite' }"></span>
54
+ <Icon v-else-if="glyphIcon" :name="glyphIcon" :size="s.glyph" :stroke-width="2.5" />
55
+ <span v-else-if="glyphText">{{ glyphText }}</span>
56
+ </span>
13
57
  </span>
14
- <span v-if="label" :style="{ fontFamily: 'var(--font-sans)', fontWeight: 'var(--weight-medium)', fontSize: size === 'sm' ? 'var(--text-xs)' : 'var(--text-sm)', lineHeight: 'var(--leading-sm)', color: 'var(--text-body)' }">{{ label }}</span>
15
- </label>
58
+ <span v-if="labelOn != null" :style="cap(checked)" @click="flip">{{ labelOn }}</span>
59
+ <span v-if="label != null && labelOn == null && labelOff == null" :style="{ fontSize: size === 'sm' ? 'var(--text-xs)' : 'var(--text-sm)', lineHeight: 1, fontFamily: 'var(--font-sans)', fontWeight: 500, color: 'var(--text-body)', cursor: inert ? 'default' : 'pointer', userSelect: 'none' }" @click="flip">{{ label }}</span>
60
+ </span>
16
61
  </template>
@@ -15,9 +15,12 @@ Form controls, split by component (each with its own specimen card covering defa
15
15
  Everything is 12px text in 36px controls; readOnly inputs go #fafafa; disabled = 50% opacity; error = red border + red helper text via `Field`'s `error` prop.
16
16
 
17
17
 
18
- `Switch` / `Radio` / `RadioGroup` (additions, not in the source file):
18
+ `Switch` / `Radio` / `RadioGroup` (additions, not in the source file). Switch: default 36×20 blue-on / border-strong-off; `size="lg"` (44×24) whenever the knob carries a glyph — `knobIcon` (string or `{on,off}`) or `knobText` (ONE letter / ONE CJK char); `insideOn`/`insideOff` put 1–3 characters inside the pill; `labelOn`/`labelOff` put captions on both sides with the active one in text-heading; `tone="semantic"` = danger off → success on, only for states with consequence (a live integration, a kill switch); `beforeChange` async-gates the flip with a spinner in the knob, `loading` does the same under your control.
19
19
 
20
20
  ```jsx
21
21
  <Switch label="Debug Mode" checked={on} onChange={setOn}/>
22
+ <Switch size="lg" tone="semantic" knobIcon={{on:"check",off:"x"}} checked={live} beforeChange={n=>api.setLive(n)}/>
23
+ <Switch labelOff="Monthly" labelOn="Annual" checked={annual} onChange={setAnnual}/>
22
24
  <RadioGroup options={["New","Overwrite","Skip"]} value={v} onChange={setV}/>
25
+ <RadioGroup variant="cards" row value={m} onChange={setM} options={[{label:"Merge",value:"merge",description:"Keep target values, add new keys"},{label:"Overwrite",value:"overwrite",description:"Target takes source values"}]}/>
23
26
  ```
@@ -65,6 +65,7 @@ export * from "./forms/Field";
65
65
  export * from "./forms/Input";
66
66
  export * from "./forms/MultiSelect";
67
67
  export * from "./forms/NumberStepper";
68
+ export * from "./forms/Radio";
68
69
  export * from "./forms/SearchInput";
69
70
  export * from "./forms/Select";
70
71
  export * from "./forms/Slider";
@@ -80,10 +80,11 @@ export { Field } from "./forms/Field.jsx";
80
80
  export { Input, Textarea } from "./forms/Input.jsx";
81
81
  export { MultiSelect } from "./forms/MultiSelect.jsx";
82
82
  export { NumberStepper } from "./forms/NumberStepper.jsx";
83
+ export { Radio, RadioGroup } from "./forms/Radio.jsx";
83
84
  export { SearchInput } from "./forms/SearchInput.jsx";
84
85
  export { Select } from "./forms/Select.jsx";
85
86
  export { Slider } from "./forms/Slider.jsx";
86
- export { Radio, RadioGroup, Switch } from "./forms/Switch.jsx";
87
+ export { Switch } from "./forms/Switch.jsx";
87
88
 
88
89
  /* icons/ */
89
90
  export { ICON_PATHS, Icon } from "./icons/Icon.jsx";
@@ -1,11 +1,18 @@
1
1
  import * as React from "react";
2
- /** Inset segmented control with a raised active segment. Addition — not in the source file. */
2
+ /** Inset segmented control with a raised active segment — switches views of the same data, applies instantly. ← → move between enabled segments. */
3
3
  export interface SegmentedControlProps {
4
- /** Plain labels, or {label, icon} entries */
5
- options?: (string | { label: string; icon?: string })[];
4
+ /** Plain labels, or { label, icon?, disabled? } entries. Value is matched by label. */
5
+ options?: (string | { label: string; icon?: string; disabled?: boolean })[];
6
6
  value?: string;
7
7
  onChange?: (value: string) => void;
8
+ /** md 32px (default) · sm 26px */
8
9
  size?: "sm" | "md";
10
+ /** Hide labels; each label becomes the segment's aria-label and tooltip */
11
+ iconOnly?: boolean;
12
+ /** Greys the whole control */
13
+ disabled?: boolean;
14
+ /** Stretch to the container, segments share the width evenly */
15
+ fullWidth?: boolean;
9
16
  style?: React.CSSProperties;
10
17
  }
11
18
  export declare function SegmentedControl(props: SegmentedControlProps): JSX.Element;
@@ -1,18 +1,33 @@
1
1
  import React from "react";
2
2
  import { Icon } from "../icons/Icon.jsx";
3
3
 
4
- /** Segmented control — inset track, raised active segment. options: string[] or [{label,icon}]. */
5
- export function SegmentedControl({ options = [], value, onChange, size = "md", style }) {
4
+ /**
5
+ * Segmented control — inset track, raised active segment. Switches views of the same data, applies instantly.
6
+ * options: string[] | { label, icon?, disabled? }[] value/onChange by label
7
+ * size "md" (32) | "sm" (26) · iconOnly hides labels (label becomes aria-label / title)
8
+ * disabled greys the whole control · fullWidth stretches segments evenly
9
+ * Keyboard: ← → move between enabled segments.
10
+ */
11
+ export function SegmentedControl({ options = [], value, onChange, size = "md", iconOnly, disabled, fullWidth, style }) {
6
12
  const h = size === "sm" ? 26 : 32;
13
+ const opts = options.map((o) => (typeof o === "string" ? { label: o } : o));
14
+ const enabled = opts.filter((o) => !o.disabled);
15
+ const onKey = (e) => {
16
+ if (e.key !== "ArrowLeft" && e.key !== "ArrowRight" || disabled || !enabled.length) return;
17
+ e.preventDefault();
18
+ const i = enabled.findIndex((o) => o.label === value);
19
+ const n = e.key === "ArrowRight" ? enabled[(i + 1) % enabled.length] : enabled[(i - 1 + enabled.length) % enabled.length];
20
+ onChange && onChange(n.label);
21
+ };
7
22
  return (
8
- <div style={{ display: "inline-flex", padding: 2, gap: 2, background: "var(--bg-inset)", border: "1px solid var(--border-control)", borderRadius: "var(--radius-md)", boxSizing: "border-box", ...style }}>
9
- {options.map((o) => {
10
- const label = typeof o === "string" ? o : o.label;
11
- const is = value === label;
23
+ <div role="tablist" aria-disabled={disabled || undefined} onKeyDown={onKey} style={{ display: fullWidth ? "flex" : "inline-flex", padding: 2, gap: 2, background: "var(--bg-inset)", border: "1px solid var(--border-control)", borderRadius: "var(--radius-md)", boxSizing: "border-box", opacity: disabled ? .5 : 1, ...style }}>
24
+ {opts.map((o) => {
25
+ const is = value === o.label, off = disabled || o.disabled;
12
26
  return (
13
- <button key={label} type="button" onClick={() => onChange && onChange(label)}
14
- style={{ height: h - 6, padding: "0 10px", display: "inline-flex", alignItems: "center", gap: 6, borderRadius: "var(--radius-sm)", border: "none", cursor: "pointer", background: is ? "var(--bg-surface)" : "transparent", boxShadow: is ? "0 1px 2px rgba(0,0,0,.08)" : "none", fontFamily: "var(--font-sans)", fontWeight: "var(--weight-medium)", fontSize: size === "sm" ? "var(--text-xs)" : "var(--text-sm)", color: is ? "var(--text-heading)" : "var(--text-secondary)", whiteSpace: "nowrap", transition: "var(--t-hover)" }}>
15
- {typeof o === "object" && o.icon ? <Icon name={o.icon} size={13} /> : null}{label}
27
+ <button key={o.label} type="button" role="tab" aria-selected={is} aria-label={iconOnly ? o.label : undefined} title={iconOnly ? o.label : undefined} disabled={off} tabIndex={is ? 0 : -1}
28
+ onClick={() => !off && onChange && onChange(o.label)}
29
+ style={{ height: h - 6, padding: iconOnly ? "0 8px" : "0 10px", flex: fullWidth ? 1 : "none", justifyContent: "center", display: "inline-flex", alignItems: "center", gap: 6, borderRadius: "var(--radius-sm)", border: "none", cursor: off ? "not-allowed" : "pointer", background: is ? "var(--bg-surface)" : "transparent", boxShadow: is ? "0 1px 2px rgba(0,0,0,.08)" : "none", fontFamily: "var(--font-sans)", fontWeight: "var(--weight-medium)", fontSize: size === "sm" ? "var(--text-xs)" : "var(--text-sm)", color: is ? "var(--text-heading)" : "var(--text-secondary)", opacity: o.disabled && !disabled ? .45 : 1, whiteSpace: "nowrap", transition: "var(--t-hover)" }}>
30
+ {o.icon ? <Icon name={o.icon} size={13} /> : null}{iconOnly ? null : o.label}
16
31
  </button>
17
32
  );
18
33
  })}
@@ -1,15 +1,27 @@
1
1
  <script setup>
2
+ import { computed } from 'vue'
2
3
  import Icon from '../icons/Icon.vue'
3
- // Segmented control — inset track, raised active segment. options: string[] or [{label,icon}].
4
- const props = defineProps({ options: { type: Array, default: () => [] }, modelValue: String, size: { type: String, default: 'md' }, style: Object })
4
+ // Segmented control — inset track, raised active segment. Switches views of the same data, applies instantly.
5
+ // options: string[] | { label, icon?, disabled? }[] · v-model by label · size md (32) | sm (26)
6
+ // iconOnly hides labels (label → aria-label/title) · disabled greys the control · fullWidth stretches segments · ← → keyboard.
7
+ const props = defineProps({ options: { type: Array, default: () => [] }, modelValue: String, size: { type: String, default: 'md' }, iconOnly: Boolean, disabled: Boolean, fullWidth: Boolean, style: Object })
5
8
  const emit = defineEmits(['update:modelValue'])
6
- function label(o) { return typeof o === 'string' ? o : o.label }
9
+ const opts = computed(() => props.options.map(o => typeof o === 'string' ? { label: o } : o))
10
+ const enabled = computed(() => opts.value.filter(o => !o.disabled))
11
+ const h = computed(() => props.size === 'sm' ? 26 : 32)
12
+ const pick = (o) => { if (!(props.disabled || o.disabled)) emit('update:modelValue', o.label) }
13
+ function onKey(e) {
14
+ if ((e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') || props.disabled || !enabled.value.length) return
15
+ e.preventDefault()
16
+ const i = enabled.value.findIndex(o => o.label === props.modelValue), n = enabled.value.length
17
+ emit('update:modelValue', (e.key === 'ArrowRight' ? enabled.value[(i + 1) % n] : enabled.value[(i - 1 + n) % n]).label)
18
+ }
7
19
  </script>
8
20
  <template>
9
- <div :style="{ display: 'inline-flex', padding: 2, gap: 2, background: 'var(--bg-inset)', border: '1px solid var(--border-control)', borderRadius: 'var(--radius-md)', boxSizing: 'border-box', ...style }">
10
- <button v-for="o in options" :key="label(o)" type="button" @click="emit('update:modelValue', label(o))"
11
- :style="{ height: (size === 'sm' ? 26 : 32) - 6, padding: '0 10px', display: 'inline-flex', alignItems: 'center', gap: 6, borderRadius: 'var(--radius-sm)', border: 'none', cursor: 'pointer', background: modelValue === label(o) ? 'var(--bg-surface)' : 'transparent', boxShadow: modelValue === label(o) ? '0 1px 2px rgba(0,0,0,.08)' : 'none', fontFamily: 'var(--font-sans)', fontWeight: 'var(--weight-medium)', fontSize: size === 'sm' ? 'var(--text-xs)' : 'var(--text-sm)', color: modelValue === label(o) ? 'var(--text-heading)' : 'var(--text-secondary)', whiteSpace: 'nowrap', transition: 'var(--t-hover)' }">
12
- <Icon v-if="typeof o === 'object' && o.icon" :name="o.icon" :size="13" />{{ label(o) }}
21
+ <div role="tablist" :aria-disabled="disabled || undefined" @keydown="onKey" :style="{ display: fullWidth ? 'flex' : 'inline-flex', padding: '2px', gap: '2px', background: 'var(--bg-inset)', border: '1px solid var(--border-control)', borderRadius: 'var(--radius-md)', boxSizing: 'border-box', opacity: disabled ? .5 : 1, ...style }">
22
+ <button v-for="o in opts" :key="o.label" type="button" role="tab" :aria-selected="modelValue === o.label" :aria-label="iconOnly ? o.label : undefined" :title="iconOnly ? o.label : undefined" :disabled="disabled || o.disabled" :tabindex="modelValue === o.label ? 0 : -1" @click="pick(o)"
23
+ :style="{ height: (h - 6) + 'px', padding: iconOnly ? '0 8px' : '0 10px', flex: fullWidth ? 1 : 'none', justifyContent: 'center', display: 'inline-flex', alignItems: 'center', gap: '6px', borderRadius: 'var(--radius-sm)', border: 'none', cursor: (disabled || o.disabled) ? 'not-allowed' : 'pointer', background: modelValue === o.label ? 'var(--bg-surface)' : 'transparent', boxShadow: modelValue === o.label ? '0 1px 2px rgba(0,0,0,.08)' : 'none', fontFamily: 'var(--font-sans)', fontWeight: 'var(--weight-medium)', fontSize: size === 'sm' ? 'var(--text-xs)' : 'var(--text-sm)', color: modelValue === o.label ? 'var(--text-heading)' : 'var(--text-secondary)', opacity: o.disabled && !disabled ? .45 : 1, whiteSpace: 'nowrap', transition: 'var(--t-hover)' }">
24
+ <Icon v-if="o.icon" :name="o.icon" :size="13" /><template v-if="!iconOnly">{{ o.label }}</template>
13
25
  </button>
14
26
  </div>
15
27
  </template>
@@ -7,8 +7,10 @@ Tab systems: `Tabs` (13px underline — page level), `PanelTabs` (12px underline
7
7
  <ViewToggle value={view} onChange={setView}/>
8
8
  ```
9
9
 
10
- `SegmentedControl` (addition) for view/range switches:
10
+ `SegmentedControl` (addition) for view/range switches — same data, instant; never a saved value and never page navigation. `iconOnly`, per-option `disabled`, `fullWidth`:
11
11
 
12
12
  ```jsx
13
13
  <SegmentedControl options={[{label:"List",icon:"list"},{label:"Grid",icon:"layout-grid"}]} value={v} onChange={setV}/>
14
+ <SegmentedControl iconOnly options={[{icon:"table",label:"Table"},{icon:"chart-column",label:"Chart"}]} value={m} onChange={setM}/>
15
+ <SegmentedControl fullWidth options={["Overview","Details","History"]} value={t} onChange={setT}/>
14
16
  ```