@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.
- package/DEPLOY.md +3 -3
- package/INTEGRATION.md +6 -5
- package/_adherence.oxlintrc.json +21 -6
- package/components/_cards/radio.card.html +46 -0
- package/components/_cards/segmented-control.card.html +39 -0
- package/components/_cards/toggles.card.html +44 -55
- package/components/_shared/src-forms.js +1 -1
- package/components/_shared/src-tabs.js +1 -1
- package/components/_shared/view-source.js +9 -4
- package/components/forms/Radio.d.ts +28 -0
- package/components/forms/Radio.jsx +67 -0
- package/components/forms/Radio.vue +17 -8
- package/components/forms/RadioGroup.vue +22 -4
- package/components/forms/Switch.d.ts +32 -24
- package/components/forms/Switch.jsx +87 -35
- package/components/forms/Switch.vue +56 -11
- package/components/forms/forms.prompt.md +4 -1
- package/components/index.d.ts +1 -0
- package/components/index.js +2 -1
- package/components/tabs/SegmentedControl.d.ts +10 -3
- package/components/tabs/SegmentedControl.jsx +24 -9
- package/components/tabs/SegmentedControl.vue +19 -7
- package/components/tabs/tabs.prompt.md +3 -1
- package/package.json +1 -1
- package/readme.md +0 -1
- package/tokens/tokens.json +1 -1
|
@@ -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
|
-
|
|
5
|
-
const
|
|
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
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
4
|
-
|
|
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:
|
|
9
|
-
<
|
|
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
|
-
/**
|
|
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
|
|
6
|
-
label?: string;
|
|
7
|
-
size?: "md" | "sm";
|
|
9
|
+
onChange?(next: boolean): void;
|
|
8
10
|
disabled?: boolean;
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
|
|
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
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
<
|
|
11
|
-
<span
|
|
12
|
-
|
|
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="
|
|
15
|
-
|
|
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
|
```
|
package/components/index.d.ts
CHANGED
|
@@ -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";
|
package/components/index.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
/**
|
|
5
|
-
|
|
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
|
-
{
|
|
10
|
-
const
|
|
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"
|
|
14
|
-
|
|
15
|
-
{
|
|
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.
|
|
4
|
-
|
|
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
|
-
|
|
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:
|
|
10
|
-
<button v-for="o in
|
|
11
|
-
:style="{ height: (
|
|
12
|
-
<Icon v-if="
|
|
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
|
```
|