@keyfunc/keybricks-ds 1.0.2 → 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 +24 -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-buttons.js +1 -1
- package/components/_shared/src-data.js +1 -1
- package/components/_shared/src-display.js +1 -1
- package/components/_shared/src-feedback.js +1 -1
- package/components/_shared/src-forms.js +1 -1
- package/components/_shared/src-navigation.js +1 -1
- package/components/_shared/src-pipeline.js +1 -1
- package/components/_shared/src-tabs.js +1 -1
- package/components/_shared/view-source.js +9 -4
- package/components/_vue/index.mjs +21 -0
- package/components/buttons/Button.vue +0 -20
- package/components/buttons/ButtonGroup.vue +16 -0
- package/components/data/KeyValue.vue +0 -16
- package/components/data/KeyValueList.vue +12 -0
- package/components/data/ListRow.vue +1 -1
- package/components/display/AreaChart.vue +14 -0
- package/components/display/Avatar.vue +0 -17
- package/components/display/AvatarGroup.vue +13 -0
- package/components/display/Badge.vue +0 -34
- package/components/display/Card.vue +0 -15
- package/components/display/Donut.vue +17 -0
- package/components/display/IconTile.vue +13 -0
- package/components/display/MiniBars.vue +11 -0
- package/components/display/ProgressBar.vue +0 -54
- package/components/display/StatCard.vue +11 -0
- package/components/display/StatusBadge.vue +11 -0
- package/components/feedback/ActivityItem.vue +1 -32
- package/components/feedback/Alert.vue +0 -36
- package/components/feedback/DotActivity.vue +15 -0
- package/components/feedback/EmptyState.vue +12 -0
- package/components/feedback/LogViewer.vue +8 -0
- package/components/feedback/Modal.vue +0 -45
- package/components/feedback/Skeleton.vue +6 -0
- package/components/feedback/Spinner.vue +6 -0
- package/components/feedback/Toast.vue +19 -0
- package/components/forms/Combobox.vue +1 -1
- package/components/forms/DatePicker.vue +1 -1
- package/components/forms/Input.jsx +2 -1
- package/components/forms/Input.vue +2 -24
- 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/Textarea.vue +20 -0
- package/components/forms/forms.prompt.md +4 -1
- package/components/forms/input-base.js +3 -0
- package/components/index.d.ts +1 -0
- package/components/index.js +2 -1
- package/components/navigation/Breadcrumb.vue +0 -22
- package/components/navigation/PageHeading.vue +9 -0
- package/components/navigation/PageToolbar.vue +15 -0
- package/components/navigation/ThemeToggle.vue +18 -0
- package/components/navigation/TopBar.vue +0 -32
- package/components/pipeline/NodeCard.vue +2 -1
- package/components/pipeline/PaletteItem.vue +1 -1
- package/components/pipeline/PalettePanel.jsx +2 -8
- package/components/pipeline/PalettePanel.vue +1 -8
- package/components/pipeline/pipeline-catalog.js +8 -0
- package/components/tabs/PanelTabs.vue +10 -0
- package/components/tabs/PillTabs.vue +10 -0
- 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.vue +0 -28
- package/components/tabs/tabs.prompt.md +3 -1
- package/package.json +1 -1
- package/tokens/tokens.json +1 -1
|
@@ -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>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { ref, computed } from 'vue'
|
|
3
|
+
import { inputBase } from './input-base.js'
|
|
4
|
+
// Multiline textarea, r8, default 90px tall. v-model. showCount + maxLength renders a bottom-right "n/max" counter (red past the limit).
|
|
5
|
+
const props = defineProps({ modelValue: String, rows: { type: Number, default: 4 }, error: Boolean, disabled: Boolean, showCount: Boolean, maxLength: Number, style: Object })
|
|
6
|
+
defineEmits(['update:modelValue'])
|
|
7
|
+
const focus = ref(false)
|
|
8
|
+
const len = computed(() => (props.modelValue || '').length)
|
|
9
|
+
const over = computed(() => props.maxLength != null && len.value > props.maxLength)
|
|
10
|
+
const s = computed(() => ({ ...inputBase, height: '90px', resize: 'vertical', paddingBottom: props.showCount ? '22px' : '8px',
|
|
11
|
+
...((props.error || over.value) ? { borderColor: 'var(--danger)' } : null),
|
|
12
|
+
...(focus.value && !props.disabled ? { borderColor: (props.error || over.value) ? 'var(--danger)' : 'var(--focus-border)', boxShadow: `0 0 0 2px ${(props.error || over.value) ? 'var(--danger-bg)' : 'var(--focus-ring)'}` } : null),
|
|
13
|
+
...(props.disabled ? { opacity: .5, cursor: 'not-allowed', background: 'var(--bg-inset)' } : null), ...props.style }))
|
|
14
|
+
</script>
|
|
15
|
+
<template>
|
|
16
|
+
<div style="position:relative">
|
|
17
|
+
<textarea :rows="rows" :disabled="disabled" :maxlength="showCount ? undefined : maxLength" :value="modelValue" @input="$emit('update:modelValue', $event.target.value)" @focus="focus = true" @blur="focus = false" :style="s"></textarea>
|
|
18
|
+
<span v-if="showCount" :style="{ position: 'absolute', right: '10px', bottom: '7px', fontFamily: 'var(--font-mono)', fontSize: 'var(--text-xs)', color: over ? 'var(--danger)' : 'var(--text-muted)' }">{{ len }}{{ maxLength != null ? `/${maxLength}` : '' }}</span>
|
|
19
|
+
</div>
|
|
20
|
+
</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
|
```
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
// Shared base style for text-like controls (Input, Textarea, Combobox, DatePicker).
|
|
2
|
+
// Lives in a plain module because <script setup> cannot export values.
|
|
3
|
+
export const inputBase = { height: 'var(--h-input)', borderRadius: 'var(--radius-md)', border: '1px solid var(--border-control)', background: 'var(--bg-surface)', padding: '8px 12px', fontFamily: 'var(--font-sans)', fontSize: 'var(--text-sm)', lineHeight: 'var(--leading-sm)', color: 'var(--text-primary)', outline: 'none', boxSizing: 'border-box', width: '100%' }
|
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";
|
|
@@ -62,28 +62,6 @@ const crumbStyle = (i, it) => ({ fontFamily: 'var(--font-sans)', fontSize: 'var(
|
|
|
62
62
|
</nav>
|
|
63
63
|
</template>
|
|
64
64
|
|
|
65
|
-
<!-- ThemeToggle — flips <html data-theme> and persists to localStorage("kb-theme"). Pass `dark` (v-model) to control it externally. -->
|
|
66
|
-
<!--
|
|
67
|
-
<script setup name="ThemeToggle">
|
|
68
|
-
import { ref, computed, watch, onMounted } from 'vue'
|
|
69
|
-
import Icon from '../icons/Icon.vue'
|
|
70
|
-
const props = defineProps({ size: { type: Number, default: 34 }, dark: { type: Boolean, default: null }, style: Object })
|
|
71
|
-
const emit = defineEmits(['update:dark'])
|
|
72
|
-
const darkState = ref(false)
|
|
73
|
-
const dark = computed(() => props.dark != null ? props.dark : darkState.value)
|
|
74
|
-
const hover = ref(false)
|
|
75
|
-
onMounted(() => { darkState.value = document.documentElement.dataset.theme === 'dark' })
|
|
76
|
-
watch(dark, (v) => { if (props.dark != null) return; document.documentElement.dataset.theme = v ? 'dark' : 'light'; try { localStorage.setItem('kb-theme', v ? 'dark' : 'light') } catch (e) {} })
|
|
77
|
-
function toggle() { if (props.dark != null) emit('update:dark', !dark.value); else darkState.value = !darkState.value }
|
|
78
|
-
</script>
|
|
79
|
-
<template>
|
|
80
|
-
<button type="button" :aria-label="dark ? 'Switch to light mode' : 'Switch to dark mode'" @click="toggle" @mouseenter="hover = true" @mouseleave="hover = false"
|
|
81
|
-
:style="{ width: size, height: size, borderRadius: 'var(--radius-md)', border: 'none', cursor: 'pointer', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', background: hover ? 'var(--bg-hover)' : 'transparent', color: 'var(--text-body)', padding: 0, transition: 'var(--t-hover)', ...style }">
|
|
82
|
-
<Icon :name="dark ? 'sparkles' : 'moon'" :size="18" />
|
|
83
|
-
</button>
|
|
84
|
-
</template>
|
|
85
|
-
-->
|
|
86
|
-
|
|
87
65
|
<!-- applyStoredTheme() — reads the persisted theme and applies it before first paint. Call once at app start (main.js): -->
|
|
88
66
|
<!--
|
|
89
67
|
export function applyStoredTheme() {
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
defineProps({ title: String, subtitle: String, style: Object })
|
|
3
|
+
</script>
|
|
4
|
+
<template>
|
|
5
|
+
<div :style="{ display: 'flex', flexDirection: 'column', gap: 4, ...style }">
|
|
6
|
+
<span style="font-family:var(--font-sans);font-weight:var(--weight-medium);font-size:var(--text-lg);line-height:var(--leading-lg);letter-spacing:var(--tracking-lg);color:var(--text-primary)">{{ title }}</span>
|
|
7
|
+
<span v-if="subtitle" style="font-family:var(--font-sans);font-size:var(--text-base);line-height:var(--leading-base);letter-spacing:var(--tracking-base);color:var(--text-secondary)">{{ subtitle }}</span>
|
|
8
|
+
</div>
|
|
9
|
+
</template>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import IconButton from '../buttons/IconButton.vue'
|
|
3
|
+
defineProps({ title: String, status: { type: String, default: '• Editing' }, showBack: Boolean, style: Object })
|
|
4
|
+
const emit = defineEmits(['back'])
|
|
5
|
+
</script>
|
|
6
|
+
<template>
|
|
7
|
+
<div :style="{ height: 'var(--topbar-h)', background: 'var(--bg-surface)', borderBottom: '1px solid var(--border-card)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 16px', boxSizing: 'border-box', flexShrink: 0, ...style }">
|
|
8
|
+
<div style="display:flex;align-items:center;gap:8px">
|
|
9
|
+
<IconButton v-if="showBack" icon="chevron-left" size="md" @click="emit('back')" />
|
|
10
|
+
<span style="font-family:var(--font-sans);font-weight:var(--weight-semibold);font-size:var(--text-md);line-height:var(--leading-md);letter-spacing:var(--tracking-md);color:var(--text-heading)">{{ title }}</span>
|
|
11
|
+
<span v-if="status" style="font-family:var(--font-sans);font-size:var(--text-sm);line-height:var(--leading-sm);color:var(--text-muted);margin-left:6px">{{ status }}</span>
|
|
12
|
+
</div>
|
|
13
|
+
<div style="display:flex;align-items:center;gap:8px"><slot name="actions" /></div>
|
|
14
|
+
</div>
|
|
15
|
+
</template>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { ref, computed, watch, onMounted } from 'vue'
|
|
3
|
+
import Icon from '../icons/Icon.vue'
|
|
4
|
+
const props = defineProps({ size: { type: Number, default: 34 }, dark: { type: Boolean, default: null }, style: Object })
|
|
5
|
+
const emit = defineEmits(['update:dark'])
|
|
6
|
+
const darkState = ref(false)
|
|
7
|
+
const dark = computed(() => props.dark != null ? props.dark : darkState.value)
|
|
8
|
+
const hover = ref(false)
|
|
9
|
+
onMounted(() => { darkState.value = document.documentElement.dataset.theme === 'dark' })
|
|
10
|
+
watch(dark, (v) => { if (props.dark != null) return; document.documentElement.dataset.theme = v ? 'dark' : 'light'; try { localStorage.setItem('kb-theme', v ? 'dark' : 'light') } catch (e) {} })
|
|
11
|
+
function toggle() { if (props.dark != null) emit('update:dark', !dark.value); else darkState.value = !darkState.value }
|
|
12
|
+
</script>
|
|
13
|
+
<template>
|
|
14
|
+
<button type="button" :aria-label="dark ? 'Switch to light mode' : 'Switch to dark mode'" @click="toggle" @mouseenter="hover = true" @mouseleave="hover = false"
|
|
15
|
+
:style="{ width: size, height: size, borderRadius: 'var(--radius-md)', border: 'none', cursor: 'pointer', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', background: hover ? 'var(--bg-hover)' : 'transparent', color: 'var(--text-body)', padding: 0, transition: 'var(--t-hover)', ...style }">
|
|
16
|
+
<Icon :name="dark ? 'sparkles' : 'moon'" :size="18" />
|
|
17
|
+
</button>
|
|
18
|
+
</template>
|
|
@@ -17,35 +17,3 @@ const emit = defineEmits(['search'])
|
|
|
17
17
|
</div>
|
|
18
18
|
</header>
|
|
19
19
|
</template>
|
|
20
|
-
|
|
21
|
-
<!-- PageToolbar — editor toolbar: back chevron, 14px/600 title, "• Editing" status, right action slot. -->
|
|
22
|
-
<!--
|
|
23
|
-
<script setup name="PageToolbar">
|
|
24
|
-
import IconButton from '../buttons/IconButton.vue'
|
|
25
|
-
defineProps({ title: String, status: { type: String, default: '• Editing' }, showBack: Boolean, style: Object })
|
|
26
|
-
const emit = defineEmits(['back'])
|
|
27
|
-
</script>
|
|
28
|
-
<template>
|
|
29
|
-
<div :style="{ height: 'var(--topbar-h)', background: 'var(--bg-surface)', borderBottom: '1px solid var(--border-card)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 16px', boxSizing: 'border-box', flexShrink: 0, ...style }">
|
|
30
|
-
<div style="display:flex;align-items:center;gap:8px">
|
|
31
|
-
<IconButton v-if="showBack" icon="chevron-left" size="md" @click="emit('back')" />
|
|
32
|
-
<span style="font-family:var(--font-sans);font-weight:var(--weight-semibold);font-size:var(--text-md);line-height:var(--leading-md);letter-spacing:var(--tracking-md);color:var(--text-heading)">{{ title }}</span>
|
|
33
|
-
<span v-if="status" style="font-family:var(--font-sans);font-size:var(--text-sm);line-height:var(--leading-sm);color:var(--text-muted);margin-left:6px">{{ status }}</span>
|
|
34
|
-
</div>
|
|
35
|
-
<div style="display:flex;align-items:center;gap:8px"><slot name="actions" /></div>
|
|
36
|
-
</div>
|
|
37
|
-
</template>
|
|
38
|
-
-->
|
|
39
|
-
|
|
40
|
-
<!-- PageHeading — page heading block: 15px/500 title + 13px muted subtitle. -->
|
|
41
|
-
<!--
|
|
42
|
-
<script setup name="PageHeading">
|
|
43
|
-
defineProps({ title: String, subtitle: String, style: Object })
|
|
44
|
-
</script>
|
|
45
|
-
<template>
|
|
46
|
-
<div :style="{ display: 'flex', flexDirection: 'column', gap: 4, ...style }">
|
|
47
|
-
<span style="font-family:var(--font-sans);font-weight:var(--weight-medium);font-size:var(--text-lg);line-height:var(--leading-lg);letter-spacing:var(--tracking-lg);color:var(--text-primary)">{{ title }}</span>
|
|
48
|
-
<span v-if="subtitle" style="font-family:var(--font-sans);font-size:var(--text-base);line-height:var(--leading-base);letter-spacing:var(--tracking-base);color:var(--text-secondary)">{{ subtitle }}</span>
|
|
49
|
-
</div>
|
|
50
|
-
</template>
|
|
51
|
-
-->
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
<script setup>
|
|
2
2
|
import { ref } from 'vue'
|
|
3
3
|
import Icon from '../icons/Icon.vue'
|
|
4
|
-
import
|
|
4
|
+
import IconTile from '../display/IconTile.vue'
|
|
5
|
+
import StatusBadge from '../display/StatusBadge.vue'
|
|
5
6
|
/**
|
|
6
7
|
* Canvas node card — 220×90, r16, soft shadow. Selected = blue border + blue-100 ring.
|
|
7
8
|
* badge: "error" | "warning" | "clock" overlay on the icon tile.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<script setup>
|
|
2
2
|
import { ref } from 'vue'
|
|
3
|
-
import
|
|
3
|
+
import IconTile from '../display/IconTile.vue'
|
|
4
4
|
// Palette grid tile — 40px tinted icon + 10px two-line label.
|
|
5
5
|
defineProps({ icon: String, hue: String, label: String, style: Object })
|
|
6
6
|
defineEmits(['click'])
|
|
@@ -2,6 +2,7 @@ import React from "react";
|
|
|
2
2
|
import { Icon } from "../icons/Icon.jsx";
|
|
3
3
|
import { IconTile } from "../display/Badge.jsx";
|
|
4
4
|
import { ViewToggle } from "../tabs/ViewToggle.jsx";
|
|
5
|
+
import { PIPELINE_CATALOG } from "./pipeline-catalog.js";
|
|
5
6
|
|
|
6
7
|
/** Palette grid tile — 40px tinted icon + 10px two-line label. */
|
|
7
8
|
export function PaletteItem({ icon, hue, label, onClick, style }) {
|
|
@@ -27,14 +28,7 @@ export function PaletteListItem({ icon, hue, label, onClick, style }) {
|
|
|
27
28
|
);
|
|
28
29
|
}
|
|
29
30
|
|
|
30
|
-
|
|
31
|
-
export const PIPELINE_CATALOG = [
|
|
32
|
-
{ label: "Data Source", icon: "table", hue: "source", items: [{ label: "Detail Sheet", icon: "table" }, { label: "Model", icon: "layers" }, { label: "Dimension", icon: "grid-3x3" }] },
|
|
33
|
-
{ label: "Data Join", icon: "text-search", hue: "join", items: [{ label: "Lookup Data", icon: "text-search" }, { label: "Association Match", icon: "shuffle" }] },
|
|
34
|
-
{ label: "Data Transformation", icon: "sliders-horizontal", hue: "transform", items: [{ label: "Field Value Calculation", icon: "sliders-horizontal" }, { label: "Field Split", icon: "split" }, { label: "Row to Column", icon: "arrow-right-left" }, { label: "Column to Row", icon: "menu" }, { label: "Multi-Condition Judge", icon: "funnel" }, { label: "Regional Calculation", icon: "chart-no-axes-column" }, { label: "Advanced Transformation", icon: "trending-up" }, { label: "JS Enhanced Processing", icon: "code" }] },
|
|
35
|
-
{ label: "Data Merge", icon: "database", hue: "merge", items: [{ label: "Group Calculation", icon: "database" }, { label: "Concat Merge", icon: "merge" }] },
|
|
36
|
-
{ label: "Data Output", icon: "table", hue: "output", items: [{ label: "Detail Sheet", icon: "table" }, { label: "Model", icon: "layers" }, { label: "Dimension", icon: "grid-3x3" }] },
|
|
37
|
-
];
|
|
31
|
+
export { PIPELINE_CATALOG };
|
|
38
32
|
|
|
39
33
|
/** 240px "Components" palette panel with list/grid toggle and collapsible hued categories. */
|
|
40
34
|
export function PalettePanel({ catalog = PIPELINE_CATALOG, height = "100%", onPick, style }) {
|