@keyfunc/keybricks-ds 2.4.6 → 2.4.8
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 +2 -2
- package/DESIGN.md +4 -4
- package/INTEGRATION.md +24 -2
- package/_adherence.oxlintrc.json +4 -4
- package/assets/icons/icon-index.js +1 -1
- package/assets/icons/icon-index.json +1 -1
- package/components/_cards/segmented-control.card.html +1 -1
- package/components/_cards/table.card.html +139 -6
- package/components/_cards/toggles.card.html +27 -27
- package/components/_shared/src-data.js +1 -1
- package/components/_shared/src-display.js +1 -1
- package/components/_shared/src-forms.js +1 -1
- package/components/_shared/src-icons.js +1 -1
- package/components/_shared/src-navigation.js +1 -1
- package/components/_shared/src-tabs.js +1 -1
- package/components/data/SmartTable.d.ts +9 -1
- package/components/data/SmartTable.jsx +24 -4
- package/components/data/SmartTable.vue +18 -5
- package/components/data/Spreadsheet.d.ts +5 -1
- package/components/data/Spreadsheet.jsx +23 -3
- package/components/data/Spreadsheet.vue +18 -5
- package/components/data/Table.d.ts +25 -4
- package/components/data/Table.jsx +74 -20
- package/components/data/Table.vue +56 -14
- package/components/display/Tag.d.ts +1 -1
- package/components/display/Tag.jsx +1 -1
- package/components/display/Tag.vue +1 -1
- package/components/forms/Switch.d.ts +2 -2
- package/components/forms/Switch.jsx +10 -8
- package/components/forms/Switch.vue +5 -4
- package/components/forms/forms.prompt.md +1 -1
- package/components/icons/Icon.jsx +3 -2
- package/components/icons/icon-paths.js +3 -2
- package/components/navigation/Breadcrumb.jsx +9 -7
- package/components/navigation/Breadcrumb.vue +16 -14
- package/components/tabs/CountBadge.vue +1 -1
- package/components/tabs/Tabs.d.ts +1 -1
- package/components/tabs/Tabs.jsx +1 -1
- package/package.json +1 -1
- package/tokens/layout.css +5 -5
|
@@ -1 +1 @@
|
|
|
1
|
-
window.__KB_SRC__=Object.assign(window.__KB_SRC__||{},{"tabs/SegmentedControl.jsx":"import React from \"react\";\nimport { Icon } from \"../icons/Icon.jsx\";\nimport { CountBadge } from \"./Tabs.jsx\";\n\n/**\n * Segmented control — inset track, raised active segment. Switches views of the same data, applies instantly.\n * options: string[] | { label, icon?, disabled?, count?, showZero? }[] value/onChange by label\n * count draws a CountBadge 6px right of the label (hidden for 0 unless showZero — per option or on the control); the match stays by label.\n * size sm 28 | md 32 | lg 40 — omit it to inherit the region's tier · iconOnly hides labels (label becomes aria-label / title)\n * disabled greys the whole control · fullWidth stretches segments evenly\n * Keyboard: ← → move between enabled segments.\n */\nconst tierIcon = { width: \"var(--control-icon)\", height: \"var(--control-icon)\" };\nexport function SegmentedControl({ options = [], value, onChange, size, iconOnly, disabled, fullWidth, showZero, style }) {\n const opts = options.map((o) => (typeof o === \"string\" ? { label: o } : o));\n const enabled = opts.filter((o) => !o.disabled);\n const onKey = (e) => {\n if (e.key !== \"ArrowLeft\" && e.key !== \"ArrowRight\" || disabled || !enabled.length) return;\n e.preventDefault();\n const i = enabled.findIndex((o) => o.label === value);\n const n = e.key === \"ArrowRight\" ? enabled[(i + 1) % enabled.length] : enabled[(i - 1 + enabled.length) % enabled.length];\n onChange && onChange(n.label);\n };\n return (\n <div role=\"tablist\" data-size={size} aria-disabled={disabled || undefined} onKeyDown={onKey} style={{ display: fullWidth ? \"flex\" : \"inline-flex\", height: \"var(--control-h)\", 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 }}>\n {opts.map((o) => {\n const is = value === o.label, off = disabled || o.disabled;\n return (\n <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} data-ink={is || off ? undefined : \"\"}\n onClick={() => !off && onChange && onChange(o.label)}\n style={{ height: \"calc(var(--control-h) - 6px)\", padding: iconOnly ? \"0 calc(var(--control-px) - 4px)\" : \"0 calc(var(--control-px) - 2px)\", 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: \"var(--control-text)\", lineHeight: \"var(--control-leading)\", letterSpacing: \"var(--control-tracking)\", color: is ? \"var(--text-heading)\" : \"var(--ink, var(--text-secondary))\", opacity: o.disabled && !disabled ? .45 : 1, whiteSpace: \"nowrap\", transition: \"var(--t-hover)\" }}>\n {o.icon ? <Icon name={o.icon} size={16} style={tierIcon} /> : null}{iconOnly ? null : o.label}{iconOnly ? null : <CountBadge count={o.count} selected={is} showZero={o.showZero ?? showZero} />}\n </button>\n );\n })}\n </div>\n );\n}\n","tabs/SegmentedControl.vue":"<script setup>\nimport { computed } from 'vue'\nimport Icon from '../icons/Icon.vue'\nimport CountBadge from './CountBadge.vue'\n// Segmented control — inset track, raised active segment. Switches views of the same data, applies instantly.\n// options: string[] | { label, icon?, disabled?, count?, showZero? }[] · v-model by label · count draws a CountBadge 6px right of the label (hidden for 0 unless showZero) · size sm 28 | md 32 | lg 40 — omit it to inherit the region's tier\n// iconOnly hides labels (label → aria-label/title) · disabled greys the control · fullWidth stretches segments · ← → keyboard.\nconst tierIcon = { width: 'var(--control-icon)', height: 'var(--control-icon)' }\nconst props = defineProps({ options: { type: Array, default: () => [] }, modelValue: String, size: String, iconOnly: Boolean, disabled: Boolean, fullWidth: Boolean, showZero: Boolean, style: Object })\nconst emit = defineEmits(['update:modelValue'])\nconst opts = computed(() => props.options.map(o => typeof o === 'string' ? { label: o } : o))\nconst enabled = computed(() => opts.value.filter(o => !o.disabled))\nconst pick = (o) => { if (!(props.disabled || o.disabled)) emit('update:modelValue', o.label) }\nfunction onKey(e) {\n if ((e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') || props.disabled || !enabled.value.length) return\n e.preventDefault()\n const i = enabled.value.findIndex(o => o.label === props.modelValue), n = enabled.value.length\n emit('update:modelValue', (e.key === 'ArrowRight' ? enabled.value[(i + 1) % n] : enabled.value[(i - 1 + n) % n]).label)\n}\n</script>\n<template>\n <div role=\"tablist\" :data-size=\"size\" :aria-disabled=\"disabled || undefined\" @keydown=\"onKey\" :style=\"{ display: fullWidth ? 'flex' : 'inline-flex', height: 'var(--control-h)', 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 }\">\n <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\" :data-ink=\"modelValue === o.label || disabled || o.disabled ? undefined : ''\" @click=\"pick(o)\"\n :style=\"{ height: 'calc(var(--control-h) - 6px)', padding: iconOnly ? '0 calc(var(--control-px) - 4px)' : '0 calc(var(--control-px) - 2px)', 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: 'var(--control-text)', lineHeight: 'var(--control-leading)', letterSpacing: 'var(--control-tracking)', color: modelValue === o.label ? 'var(--text-heading)' : 'var(--ink, var(--text-secondary))', opacity: o.disabled && !disabled ? .45 : 1, whiteSpace: 'nowrap', transition: 'var(--t-hover)' }\">\n <Icon v-if=\"o.icon\" :name=\"o.icon\" :size=\"16\" :style=\"tierIcon\" /><template v-if=\"!iconOnly\">{{ o.label }}<CountBadge :count=\"o.count\" :selected=\"modelValue === o.label\" :show-zero=\"o.showZero ?? showZero\" /></template>\n </button>\n </div>\n</template>\n","tabs/Tabs.jsx":"import React from \"react\";\n\n/* tabs: string[] | { label, count?, showZero? }[] — shared by Tabs, PanelTabs and PillTabs; active is matched by label. */\nconst norm = (tabs) => tabs.map((t) => (typeof t === \"string\" ? { label: t } : t));\n\n/** Underline tabs on a hairline baseline (Import/Export). Active = blue text + 2px blue underline. The bar is the region's bar height (sm 32 · md 40 · lg 44) with the tier's control text; omit `size` to inherit.\n * A tab's count renders as a CountBadge 6px right of the label (the badge stays neutral under the blue active tab). */\nexport function Tabs({ tabs = [], active, onChange, size, showZero, style }) {\n return (\n <div role=\"tablist\" data-size={size} style={{ display: \"flex\", gap: 4, borderBottom: \"1px solid var(--border-control)\", ...style }}>\n {norm(tabs).map((t) => {\n const is = t.label === active;\n return (\n <button key={t.label} type=\"button\" role=\"tab\" aria-selected={is} data-ink={is ? undefined : \"\"} onClick={() => onChange && onChange(t.label)}\n style={{ background: \"transparent\", border: \"none\", cursor: \"pointer\", height: \"var(--bar-h)\", boxSizing: \"border-box\", padding: \"0 var(--control-px)\", marginBottom: -1, borderBottom: is ? \"2px solid var(--action-primary)\" : \"2px solid transparent\", display: \"inline-flex\", alignItems: \"center\", gap: 6, fontFamily: \"var(--font-sans)\", fontWeight: \"var(--weight-medium)\", fontSize: \"var(--control-text)\", lineHeight: \"var(--control-leading)\", letterSpacing: \"var(--control-tracking)\", color: is ? \"var(--action-primary)\" : \"var(--ink, var(--text-body))\", whiteSpace: \"nowrap\" }}>\n {t.label}<CountBadge count={t.count} selected={is} showZero={t.showZero ?? showZero} />\n </button>\n );\n })}\n </div>\n );\n}\n\n/** Panel tabs (Node Info · Calc Rules · Output Fields · SQL Preview): a lighter underline tab bar at the region's bar height with regular-weight control text. Put the panel in an sm region for the compact 32px/12px look. */\nexport function PanelTabs({ tabs = [], active, onChange, size, showZero, style }) {\n return (\n <div role=\"tablist\" data-size={size} style={{ display: \"flex\", borderBottom: \"1px solid var(--border-subtle)\", ...style }}>\n {norm(tabs).map((t) => {\n const is = t.label === active;\n return (\n <button key={t.label} type=\"button\" role=\"tab\" aria-selected={is} data-ink={is ? undefined : \"\"} onClick={() => onChange && onChange(t.label)}\n style={{ background: \"transparent\", border: \"none\", cursor: \"pointer\", height: \"var(--bar-h)\", boxSizing: \"border-box\", padding: \"0 var(--control-px)\", marginBottom: -1, borderBottom: is ? \"2px solid var(--action-primary)\" : \"2px solid transparent\", display: \"inline-flex\", alignItems: \"center\", gap: 6, fontFamily: \"var(--font-sans)\", fontSize: \"var(--control-text)\", lineHeight: \"var(--control-leading)\", letterSpacing: \"var(--control-tracking)\", color: is ? \"var(--action-primary)\" : \"var(--ink, var(--text-secondary))\", whiteSpace: \"nowrap\" }}>\n {t.label}<CountBadge count={t.count} selected={is} showZero={t.showZero ?? showZero} />\n </button>\n );\n })}\n </div>\n );\n}\n\n\n/* Count badge beside a segment / pill label: --count-h tall (sm 16 · md 18 · lg 20), 6px sides, full radius, 12px tabular figures\n (digits are Latin, so the size is not bumped in CJK). The fill is the alpha wash --neutral-alpha-8, so it darkens whatever it sits on by the\n same step (white tab, #f5f5f5 active pill, dark theme); the text tells selected from unselected: --text-heading vs --text-body\n (a lighter grey fails 4.5:1 on a grey fill at 12px). Hidden for 0 or a missing count unless showZero, which draws a muted 0. */\nexport function CountBadge({ count, selected, showZero, style }) {\n const zero = count === 0 || count == null;\n if (zero && !showZero) return null;\n return <span aria-hidden=\"true\" style={{ display: \"inline-flex\", alignItems: \"center\", height: \"var(--count-h)\", padding: \"0 6px\", borderRadius: \"var(--radius-full)\", background: \"var(--neutral-alpha-8)\", color: zero ? \"var(--text-muted)\" : selected ? \"var(--text-heading)\" : \"var(--text-body)\", fontFamily: \"var(--font-sans)\", fontSize: 12, lineHeight: \"var(--count-h)\", fontWeight: \"var(--weight-medium)\", letterSpacing: 0, fontVariantNumeric: \"tabular-nums\", flexShrink: 0, ...style }}>{zero ? 0 : count}</span>;\n}\n/** Text pill filter tabs (For Review · To be Submitted · For Approval). Active = tinted pill. Pills are control-height (sm 28 · md 32 · lg 40); omit `size` to inherit.\n * tabs: string[] | { label, count?, showZero? }[] — a count draws a CountBadge 6px right of the label; active is matched by label. */\nexport function PillTabs({ tabs = [], active, onChange, size, showZero, style }) {\n return (\n <div role=\"tablist\" data-size={size} style={{ display: \"flex\", gap: \"var(--control-gap)\", ...style }}>\n {norm(tabs).map((t) => {\n const is = t.label === active;\n return (\n <button key={t.label} type=\"button\" role=\"tab\" aria-selected={is} data-ink={is ? undefined : \"\"} data-wash={is ? undefined : \"\"} onClick={() => onChange && onChange(t.label)}\n style={{ background: is ? \"var(--bg-active)\" : \"var(--wash, transparent)\", border: \"none\", cursor: \"pointer\", height: \"var(--control-h)\", padding: \"0 var(--control-px)\", borderRadius: \"var(--radius-md)\", display: \"inline-flex\", alignItems: \"center\", gap: 6, fontFamily: \"var(--font-sans)\", fontWeight: \"var(--weight-medium)\", fontSize: \"var(--control-text)\", lineHeight: \"var(--control-leading)\", letterSpacing: \"var(--control-tracking)\", color: is ? \"var(--text-heading)\" : \"var(--ink, var(--text-secondary))\", whiteSpace: \"nowrap\" }}>\n {t.label}<CountBadge count={t.count} selected={is} showZero={t.showZero ?? showZero} />\n </button>\n );\n })}\n </div>\n );\n}\n","tabs/Tabs.vue":"<script setup>\nimport { computed } from 'vue'\nimport CountBadge from './CountBadge.vue'\n// Underline tabs on a hairline baseline (Import/Export). Active = blue text + 2px blue underline. The bar is the region's bar height (sm 32 · md 40 · lg 44) with the tier's control text; omit `size` to inherit.\n// tabs: string[] | { label, count?, showZero? }[] — a count renders as a CountBadge 6px right of the label (neutral under the blue active tab); active is matched by label.\nconst props = defineProps({ tabs: { type: Array, default: () => [] }, active: String, size: String, showZero: Boolean, style: Object })\nconst emit = defineEmits(['change'])\nconst items = computed(() => props.tabs.map(t => typeof t === 'string' ? { label: t } : t))\n</script>\n<template>\n <div role=\"tablist\" :data-size=\"size\" :style=\"{ display: 'flex', gap: '4px', borderBottom: '1px solid var(--border-control)', ...style }\">\n <button v-for=\"t in items\" :key=\"t.label\" type=\"button\" role=\"tab\" :aria-selected=\"t.label === active\" :data-ink=\"t.label === active ? undefined : ''\" @click=\"emit('change', t.label)\"\n :style=\"{ background: 'transparent', border: 'none', cursor: 'pointer', height: 'var(--bar-h)', boxSizing: 'border-box', padding: '0 var(--control-px)', marginBottom: '-1px', borderBottom: t.label === active ? '2px solid var(--action-primary)' : '2px solid transparent', display: 'inline-flex', alignItems: 'center', gap: '6px', fontFamily: 'var(--font-sans)', fontWeight: 'var(--weight-medium)', fontSize: 'var(--control-text)', lineHeight: 'var(--control-leading)', letterSpacing: 'var(--control-tracking)', color: t.label === active ? 'var(--action-primary)' : 'var(--ink, var(--text-body))', whiteSpace: 'nowrap' }\">{{ t.label }}<CountBadge :count=\"t.count\" :selected=\"t.label === active\" :show-zero=\"t.showZero ?? showZero\" /></button>\n </div>\n</template>\n","tabs/ViewToggle.jsx":"import React from \"react\";\nimport { IconButton } from \"../buttons/IconButton.jsx\";\n\n/** Two-state list/grid icon toggle (palette header). Two IconButtons at the region's tier; omit `size` to inherit. */\nexport function ViewToggle({ value = \"grid\", onChange, size, style }) {\n return (\n <div data-size={size} style={{ display: \"inline-flex\", gap: 2, ...style }}>\n <IconButton icon=\"list\" active={value === \"list\"} onClick={() => onChange && onChange(\"list\")} label=\"List view\" />\n <IconButton icon=\"layout-grid\" active={value === \"grid\"} onClick={() => onChange && onChange(\"grid\")} label=\"Grid view\" />\n </div>\n );\n}\n","tabs/ViewToggle.vue":"<script setup>\nimport IconButton from '../buttons/IconButton.vue'\n// Two-state list/grid icon toggle (palette header). Two IconButtons at the region's tier; omit `size` to inherit.\ndefineProps({ modelValue: { type: String, default: 'grid' }, size: String, style: Object })\nconst emit = defineEmits(['update:modelValue'])\n</script>\n<template>\n <div :data-size=\"size\" :style=\"{ display: 'inline-flex', gap: '2px', ...style }\">\n <IconButton icon=\"list\" :active=\"modelValue === 'list'\" label=\"List view\" @click=\"emit('update:modelValue', 'list')\" />\n <IconButton icon=\"layout-grid\" :active=\"modelValue === 'grid'\" label=\"Grid view\" @click=\"emit('update:modelValue', 'grid')\" />\n </div>\n</template>\n","tabs/PanelTabs.vue":"<script setup>\nimport { computed } from 'vue'\nimport CountBadge from './CountBadge.vue'\n// Panel tabs (Node Info · Calc Rules · …): a lighter underline tab bar at the region's bar height with regular-weight control text. Put the panel in an sm region for the compact 32px/12px look.\n// tabs: string[] | { label, count?, showZero? }[] — a count renders as a CountBadge 6px right of the label; active is matched by label.\nconst props = defineProps({ tabs: { type: Array, default: () => [] }, active: String, size: String, showZero: Boolean, style: Object })\nconst emit = defineEmits(['change'])\nconst items = computed(() => props.tabs.map(t => typeof t === 'string' ? { label: t } : t))\n</script>\n<template>\n <div role=\"tablist\" :data-size=\"size\" :style=\"{ display: 'flex', borderBottom: '1px solid var(--border-subtle)', ...style }\">\n <button v-for=\"t in items\" :key=\"t.label\" type=\"button\" role=\"tab\" :aria-selected=\"t.label === active\" :data-ink=\"t.label === active ? undefined : ''\" @click=\"emit('change', t.label)\"\n :style=\"{ background: 'transparent', border: 'none', cursor: 'pointer', height: 'var(--bar-h)', boxSizing: 'border-box', padding: '0 var(--control-px)', marginBottom: '-1px', borderBottom: t.label === active ? '2px solid var(--action-primary)' : '2px solid transparent', display: 'inline-flex', alignItems: 'center', gap: '6px', fontFamily: 'var(--font-sans)', fontSize: 'var(--control-text)', lineHeight: 'var(--control-leading)', letterSpacing: 'var(--control-tracking)', color: t.label === active ? 'var(--action-primary)' : 'var(--ink, var(--text-secondary))', whiteSpace: 'nowrap' }\">{{ t.label }}<CountBadge :count=\"t.count\" :selected=\"t.label === active\" :show-zero=\"t.showZero ?? showZero\" /></button>\n </div>\n</template>\n","tabs/PillTabs.vue":"<script setup>\nimport { computed } from 'vue'\nimport CountBadge from './CountBadge.vue'\n// Text pill filter tabs. Active = tinted pill. Pills are control-height (sm 28 · md 32 · lg 40); omit `size` to inherit.\n// tabs: string[] | { label, count?, showZero? }[] — a count draws a CountBadge 6px right of the label; active is matched by label.\nconst props = defineProps({ tabs: { type: Array, default: () => [] }, active: String, size: String, showZero: Boolean, style: Object })\nconst emit = defineEmits(['change'])\nconst items = computed(() => props.tabs.map(t => typeof t === 'string' ? { label: t } : t))\n</script>\n<template>\n <div role=\"tablist\" :data-size=\"size\" :style=\"{ display: 'flex', gap: 'var(--control-gap)', ...style }\">\n <button v-for=\"t in items\" :key=\"t.label\" type=\"button\" role=\"tab\" :aria-selected=\"t.label === active\" :data-ink=\"t.label === active ? undefined : ''\" :data-wash=\"t.label === active ? undefined : ''\" @click=\"emit('change', t.label)\"\n :style=\"{ background: t.label === active ? 'var(--bg-active)' : 'var(--wash, transparent)', border: 'none', cursor: 'pointer', height: 'var(--control-h)', padding: '0 var(--control-px)', borderRadius: 'var(--radius-md)', display: 'inline-flex', alignItems: 'center', gap: '6px', fontFamily: 'var(--font-sans)', fontWeight: 'var(--weight-medium)', fontSize: 'var(--control-text)', lineHeight: 'var(--control-leading)', letterSpacing: 'var(--control-tracking)', color: t.label === active ? 'var(--text-heading)' : 'var(--ink, var(--text-secondary))', whiteSpace: 'nowrap' }\">{{ t.label }}<CountBadge :count=\"t.count\" :selected=\"t.label === active\" :show-zero=\"t.showZero ?? showZero\" /></button>\n </div>\n</template>\n","tabs/CountBadge.vue":"<script setup>\nimport { computed } from 'vue'\n// Count badge beside a segment / pill label: --count-h tall (sm 16 · md 18 · lg 20), 6px sides, full radius, 12px tabular figures\n// (digits are Latin, so not bumped in CJK). Fill: the alpha wash --neutral-alpha-8, so it darkens any surface by the same step (white tab,\n// #f5f5f5 active pill, dark theme); text tells selected from unselected: --text-heading vs --text-body (a lighter grey fails 4.5:1 at 12px).\n// Hidden for 0 or a missing count unless showZero, which draws a muted 0.\nconst props = defineProps({ count: Number, selected: Boolean, showZero: Boolean, style: Object })\nconst zero = computed(() => props.count === 0 || props.count == null)\n</script>\n<template>\n <span v-if=\"!zero || showZero\" aria-hidden=\"true\" :style=\"{ display: 'inline-flex', alignItems: 'center', height: 'var(--count-h)', padding: '0 6px', borderRadius: 'var(--radius-full)', background: 'var(--neutral-alpha-8)', color: zero ? 'var(--text-muted)' : selected ? 'var(--text-heading)' : 'var(--text-body)', fontFamily: 'var(--font-sans)', fontSize: '12px', lineHeight: 'var(--count-h)', fontWeight: 'var(--weight-medium)', letterSpacing: 0, fontVariantNumeric: 'tabular-nums', flexShrink: 0, ...style }\">{{ zero ? 0 : count }}</span>\n</template>\n"});
|
|
1
|
+
window.__KB_SRC__=Object.assign(window.__KB_SRC__||{},{"tabs/SegmentedControl.jsx":"import React from \"react\";\nimport { Icon } from \"../icons/Icon.jsx\";\nimport { CountBadge } from \"./Tabs.jsx\";\n\n/**\n * Segmented control — inset track, raised active segment. Switches views of the same data, applies instantly.\n * options: string[] | { label, icon?, disabled?, count?, showZero? }[] value/onChange by label\n * count draws a CountBadge 6px right of the label (hidden for 0 unless showZero — per option or on the control); the match stays by label.\n * size sm 28 | md 32 | lg 40 — omit it to inherit the region's tier · iconOnly hides labels (label becomes aria-label / title)\n * disabled greys the whole control · fullWidth stretches segments evenly\n * Keyboard: ← → move between enabled segments.\n */\nconst tierIcon = { width: \"var(--control-icon)\", height: \"var(--control-icon)\" };\nexport function SegmentedControl({ options = [], value, onChange, size, iconOnly, disabled, fullWidth, showZero, style }) {\n const opts = options.map((o) => (typeof o === \"string\" ? { label: o } : o));\n const enabled = opts.filter((o) => !o.disabled);\n const onKey = (e) => {\n if (e.key !== \"ArrowLeft\" && e.key !== \"ArrowRight\" || disabled || !enabled.length) return;\n e.preventDefault();\n const i = enabled.findIndex((o) => o.label === value);\n const n = e.key === \"ArrowRight\" ? enabled[(i + 1) % enabled.length] : enabled[(i - 1 + enabled.length) % enabled.length];\n onChange && onChange(n.label);\n };\n return (\n <div role=\"tablist\" data-size={size} aria-disabled={disabled || undefined} onKeyDown={onKey} style={{ display: fullWidth ? \"flex\" : \"inline-flex\", height: \"var(--control-h)\", 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 }}>\n {opts.map((o) => {\n const is = value === o.label, off = disabled || o.disabled;\n return (\n <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} data-ink={is || off ? undefined : \"\"}\n onClick={() => !off && onChange && onChange(o.label)}\n style={{ height: \"calc(var(--control-h) - 6px)\", padding: iconOnly ? \"0 calc(var(--control-px) - 4px)\" : \"0 calc(var(--control-px) - 2px)\", 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: \"var(--control-text)\", lineHeight: \"var(--control-leading)\", letterSpacing: \"var(--control-tracking)\", color: is ? \"var(--text-heading)\" : \"var(--ink, var(--text-secondary))\", opacity: o.disabled && !disabled ? .45 : 1, whiteSpace: \"nowrap\", transition: \"var(--t-hover)\" }}>\n {o.icon ? <Icon name={o.icon} size={16} style={tierIcon} /> : null}{iconOnly ? null : o.label}{iconOnly ? null : <CountBadge count={o.count} selected={is} showZero={o.showZero ?? showZero} />}\n </button>\n );\n })}\n </div>\n );\n}\n","tabs/SegmentedControl.vue":"<script setup>\nimport { computed } from 'vue'\nimport Icon from '../icons/Icon.vue'\nimport CountBadge from './CountBadge.vue'\n// Segmented control — inset track, raised active segment. Switches views of the same data, applies instantly.\n// options: string[] | { label, icon?, disabled?, count?, showZero? }[] · v-model by label · count draws a CountBadge 6px right of the label (hidden for 0 unless showZero) · size sm 28 | md 32 | lg 40 — omit it to inherit the region's tier\n// iconOnly hides labels (label → aria-label/title) · disabled greys the control · fullWidth stretches segments · ← → keyboard.\nconst tierIcon = { width: 'var(--control-icon)', height: 'var(--control-icon)' }\nconst props = defineProps({ options: { type: Array, default: () => [] }, modelValue: String, size: String, iconOnly: Boolean, disabled: Boolean, fullWidth: Boolean, showZero: Boolean, style: Object })\nconst emit = defineEmits(['update:modelValue'])\nconst opts = computed(() => props.options.map(o => typeof o === 'string' ? { label: o } : o))\nconst enabled = computed(() => opts.value.filter(o => !o.disabled))\nconst pick = (o) => { if (!(props.disabled || o.disabled)) emit('update:modelValue', o.label) }\nfunction onKey(e) {\n if ((e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') || props.disabled || !enabled.value.length) return\n e.preventDefault()\n const i = enabled.value.findIndex(o => o.label === props.modelValue), n = enabled.value.length\n emit('update:modelValue', (e.key === 'ArrowRight' ? enabled.value[(i + 1) % n] : enabled.value[(i - 1 + n) % n]).label)\n}\n</script>\n<template>\n <div role=\"tablist\" :data-size=\"size\" :aria-disabled=\"disabled || undefined\" @keydown=\"onKey\" :style=\"{ display: fullWidth ? 'flex' : 'inline-flex', height: 'var(--control-h)', 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 }\">\n <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\" :data-ink=\"modelValue === o.label || disabled || o.disabled ? undefined : ''\" @click=\"pick(o)\"\n :style=\"{ height: 'calc(var(--control-h) - 6px)', padding: iconOnly ? '0 calc(var(--control-px) - 4px)' : '0 calc(var(--control-px) - 2px)', 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: 'var(--control-text)', lineHeight: 'var(--control-leading)', letterSpacing: 'var(--control-tracking)', color: modelValue === o.label ? 'var(--text-heading)' : 'var(--ink, var(--text-secondary))', opacity: o.disabled && !disabled ? .45 : 1, whiteSpace: 'nowrap', transition: 'var(--t-hover)' }\">\n <Icon v-if=\"o.icon\" :name=\"o.icon\" :size=\"16\" :style=\"tierIcon\" /><template v-if=\"!iconOnly\">{{ o.label }}<CountBadge :count=\"o.count\" :selected=\"modelValue === o.label\" :show-zero=\"o.showZero ?? showZero\" /></template>\n </button>\n </div>\n</template>\n","tabs/Tabs.jsx":"import React from \"react\";\n\n/* tabs: string[] | { label, count?, showZero? }[] — shared by Tabs, PanelTabs and PillTabs; active is matched by label. */\nconst norm = (tabs) => tabs.map((t) => (typeof t === \"string\" ? { label: t } : t));\n\n/** Underline tabs on a hairline baseline (Import/Export). Active = blue text + 2px blue underline. The bar is the region's bar height (sm 32 · md 40 · lg 44) with the tier's control text; omit `size` to inherit.\n * A tab's count renders as a CountBadge 6px right of the label (the badge stays neutral under the blue active tab). */\nexport function Tabs({ tabs = [], active, onChange, size, showZero, style }) {\n return (\n <div role=\"tablist\" data-size={size} style={{ display: \"flex\", gap: 4, borderBottom: \"1px solid var(--border-control)\", ...style }}>\n {norm(tabs).map((t) => {\n const is = t.label === active;\n return (\n <button key={t.label} type=\"button\" role=\"tab\" aria-selected={is} data-ink={is ? undefined : \"\"} onClick={() => onChange && onChange(t.label)}\n style={{ background: \"transparent\", border: \"none\", cursor: \"pointer\", height: \"var(--bar-h)\", boxSizing: \"border-box\", padding: \"0 var(--control-px)\", marginBottom: -1, borderBottom: is ? \"2px solid var(--action-primary)\" : \"2px solid transparent\", display: \"inline-flex\", alignItems: \"center\", gap: 6, fontFamily: \"var(--font-sans)\", fontWeight: \"var(--weight-medium)\", fontSize: \"var(--control-text)\", lineHeight: \"var(--control-leading)\", letterSpacing: \"var(--control-tracking)\", color: is ? \"var(--action-primary)\" : \"var(--ink, var(--text-body))\", whiteSpace: \"nowrap\" }}>\n {t.label}<CountBadge count={t.count} selected={is} showZero={t.showZero ?? showZero} />\n </button>\n );\n })}\n </div>\n );\n}\n\n/** Panel tabs (Node Info · Calc Rules · Output Fields · SQL Preview): a lighter underline tab bar at the region's bar height with regular-weight control text. Put the panel in an sm region for the compact 32px/12px look. */\nexport function PanelTabs({ tabs = [], active, onChange, size, showZero, style }) {\n return (\n <div role=\"tablist\" data-size={size} style={{ display: \"flex\", borderBottom: \"1px solid var(--border-subtle)\", ...style }}>\n {norm(tabs).map((t) => {\n const is = t.label === active;\n return (\n <button key={t.label} type=\"button\" role=\"tab\" aria-selected={is} data-ink={is ? undefined : \"\"} onClick={() => onChange && onChange(t.label)}\n style={{ background: \"transparent\", border: \"none\", cursor: \"pointer\", height: \"var(--bar-h)\", boxSizing: \"border-box\", padding: \"0 var(--control-px)\", marginBottom: -1, borderBottom: is ? \"2px solid var(--action-primary)\" : \"2px solid transparent\", display: \"inline-flex\", alignItems: \"center\", gap: 6, fontFamily: \"var(--font-sans)\", fontSize: \"var(--control-text)\", lineHeight: \"var(--control-leading)\", letterSpacing: \"var(--control-tracking)\", color: is ? \"var(--action-primary)\" : \"var(--ink, var(--text-secondary))\", whiteSpace: \"nowrap\" }}>\n {t.label}<CountBadge count={t.count} selected={is} showZero={t.showZero ?? showZero} />\n </button>\n );\n })}\n </div>\n );\n}\n\n\n/* Count badge beside a segment / pill label: --count-h tall (sm 16 · md and lg 18), 6px sides, full radius, 12px tabular figures\n (digits are Latin, so the size is not bumped in CJK). The fill is the alpha wash --neutral-alpha-8, so it darkens whatever it sits on by the\n same step (white tab, #f5f5f5 active pill, dark theme); the text tells selected from unselected: --text-heading vs --text-body\n (a lighter grey fails 4.5:1 on a grey fill at 12px). Hidden for 0 or a missing count unless showZero, which draws a muted 0. */\nexport function CountBadge({ count, selected, showZero, style }) {\n const zero = count === 0 || count == null;\n if (zero && !showZero) return null;\n return <span aria-hidden=\"true\" style={{ display: \"inline-flex\", alignItems: \"center\", height: \"var(--count-h)\", padding: \"0 6px\", borderRadius: \"var(--radius-full)\", background: \"var(--neutral-alpha-8)\", color: zero ? \"var(--text-muted)\" : selected ? \"var(--text-heading)\" : \"var(--text-body)\", fontFamily: \"var(--font-sans)\", fontSize: 12, lineHeight: \"var(--count-h)\", fontWeight: \"var(--weight-medium)\", letterSpacing: 0, fontVariantNumeric: \"tabular-nums\", flexShrink: 0, ...style }}>{zero ? 0 : count}</span>;\n}\n/** Text pill filter tabs (For Review · To be Submitted · For Approval). Active = tinted pill. Pills are control-height (sm 28 · md 32 · lg 40); omit `size` to inherit.\n * tabs: string[] | { label, count?, showZero? }[] — a count draws a CountBadge 6px right of the label; active is matched by label. */\nexport function PillTabs({ tabs = [], active, onChange, size, showZero, style }) {\n return (\n <div role=\"tablist\" data-size={size} style={{ display: \"flex\", gap: \"var(--control-gap)\", ...style }}>\n {norm(tabs).map((t) => {\n const is = t.label === active;\n return (\n <button key={t.label} type=\"button\" role=\"tab\" aria-selected={is} data-ink={is ? undefined : \"\"} data-wash={is ? undefined : \"\"} onClick={() => onChange && onChange(t.label)}\n style={{ background: is ? \"var(--bg-active)\" : \"var(--wash, transparent)\", border: \"none\", cursor: \"pointer\", height: \"var(--control-h)\", padding: \"0 var(--control-px)\", borderRadius: \"var(--radius-md)\", display: \"inline-flex\", alignItems: \"center\", gap: 6, fontFamily: \"var(--font-sans)\", fontWeight: \"var(--weight-medium)\", fontSize: \"var(--control-text)\", lineHeight: \"var(--control-leading)\", letterSpacing: \"var(--control-tracking)\", color: is ? \"var(--text-heading)\" : \"var(--ink, var(--text-secondary))\", whiteSpace: \"nowrap\" }}>\n {t.label}<CountBadge count={t.count} selected={is} showZero={t.showZero ?? showZero} />\n </button>\n );\n })}\n </div>\n );\n}\n","tabs/Tabs.vue":"<script setup>\nimport { computed } from 'vue'\nimport CountBadge from './CountBadge.vue'\n// Underline tabs on a hairline baseline (Import/Export). Active = blue text + 2px blue underline. The bar is the region's bar height (sm 32 · md 40 · lg 44) with the tier's control text; omit `size` to inherit.\n// tabs: string[] | { label, count?, showZero? }[] — a count renders as a CountBadge 6px right of the label (neutral under the blue active tab); active is matched by label.\nconst props = defineProps({ tabs: { type: Array, default: () => [] }, active: String, size: String, showZero: Boolean, style: Object })\nconst emit = defineEmits(['change'])\nconst items = computed(() => props.tabs.map(t => typeof t === 'string' ? { label: t } : t))\n</script>\n<template>\n <div role=\"tablist\" :data-size=\"size\" :style=\"{ display: 'flex', gap: '4px', borderBottom: '1px solid var(--border-control)', ...style }\">\n <button v-for=\"t in items\" :key=\"t.label\" type=\"button\" role=\"tab\" :aria-selected=\"t.label === active\" :data-ink=\"t.label === active ? undefined : ''\" @click=\"emit('change', t.label)\"\n :style=\"{ background: 'transparent', border: 'none', cursor: 'pointer', height: 'var(--bar-h)', boxSizing: 'border-box', padding: '0 var(--control-px)', marginBottom: '-1px', borderBottom: t.label === active ? '2px solid var(--action-primary)' : '2px solid transparent', display: 'inline-flex', alignItems: 'center', gap: '6px', fontFamily: 'var(--font-sans)', fontWeight: 'var(--weight-medium)', fontSize: 'var(--control-text)', lineHeight: 'var(--control-leading)', letterSpacing: 'var(--control-tracking)', color: t.label === active ? 'var(--action-primary)' : 'var(--ink, var(--text-body))', whiteSpace: 'nowrap' }\">{{ t.label }}<CountBadge :count=\"t.count\" :selected=\"t.label === active\" :show-zero=\"t.showZero ?? showZero\" /></button>\n </div>\n</template>\n","tabs/ViewToggle.jsx":"import React from \"react\";\nimport { IconButton } from \"../buttons/IconButton.jsx\";\n\n/** Two-state list/grid icon toggle (palette header). Two IconButtons at the region's tier; omit `size` to inherit. */\nexport function ViewToggle({ value = \"grid\", onChange, size, style }) {\n return (\n <div data-size={size} style={{ display: \"inline-flex\", gap: 2, ...style }}>\n <IconButton icon=\"list\" active={value === \"list\"} onClick={() => onChange && onChange(\"list\")} label=\"List view\" />\n <IconButton icon=\"layout-grid\" active={value === \"grid\"} onClick={() => onChange && onChange(\"grid\")} label=\"Grid view\" />\n </div>\n );\n}\n","tabs/ViewToggle.vue":"<script setup>\nimport IconButton from '../buttons/IconButton.vue'\n// Two-state list/grid icon toggle (palette header). Two IconButtons at the region's tier; omit `size` to inherit.\ndefineProps({ modelValue: { type: String, default: 'grid' }, size: String, style: Object })\nconst emit = defineEmits(['update:modelValue'])\n</script>\n<template>\n <div :data-size=\"size\" :style=\"{ display: 'inline-flex', gap: '2px', ...style }\">\n <IconButton icon=\"list\" :active=\"modelValue === 'list'\" label=\"List view\" @click=\"emit('update:modelValue', 'list')\" />\n <IconButton icon=\"layout-grid\" :active=\"modelValue === 'grid'\" label=\"Grid view\" @click=\"emit('update:modelValue', 'grid')\" />\n </div>\n</template>\n","tabs/PanelTabs.vue":"<script setup>\nimport { computed } from 'vue'\nimport CountBadge from './CountBadge.vue'\n// Panel tabs (Node Info · Calc Rules · …): a lighter underline tab bar at the region's bar height with regular-weight control text. Put the panel in an sm region for the compact 32px/12px look.\n// tabs: string[] | { label, count?, showZero? }[] — a count renders as a CountBadge 6px right of the label; active is matched by label.\nconst props = defineProps({ tabs: { type: Array, default: () => [] }, active: String, size: String, showZero: Boolean, style: Object })\nconst emit = defineEmits(['change'])\nconst items = computed(() => props.tabs.map(t => typeof t === 'string' ? { label: t } : t))\n</script>\n<template>\n <div role=\"tablist\" :data-size=\"size\" :style=\"{ display: 'flex', borderBottom: '1px solid var(--border-subtle)', ...style }\">\n <button v-for=\"t in items\" :key=\"t.label\" type=\"button\" role=\"tab\" :aria-selected=\"t.label === active\" :data-ink=\"t.label === active ? undefined : ''\" @click=\"emit('change', t.label)\"\n :style=\"{ background: 'transparent', border: 'none', cursor: 'pointer', height: 'var(--bar-h)', boxSizing: 'border-box', padding: '0 var(--control-px)', marginBottom: '-1px', borderBottom: t.label === active ? '2px solid var(--action-primary)' : '2px solid transparent', display: 'inline-flex', alignItems: 'center', gap: '6px', fontFamily: 'var(--font-sans)', fontSize: 'var(--control-text)', lineHeight: 'var(--control-leading)', letterSpacing: 'var(--control-tracking)', color: t.label === active ? 'var(--action-primary)' : 'var(--ink, var(--text-secondary))', whiteSpace: 'nowrap' }\">{{ t.label }}<CountBadge :count=\"t.count\" :selected=\"t.label === active\" :show-zero=\"t.showZero ?? showZero\" /></button>\n </div>\n</template>\n","tabs/PillTabs.vue":"<script setup>\nimport { computed } from 'vue'\nimport CountBadge from './CountBadge.vue'\n// Text pill filter tabs. Active = tinted pill. Pills are control-height (sm 28 · md 32 · lg 40); omit `size` to inherit.\n// tabs: string[] | { label, count?, showZero? }[] — a count draws a CountBadge 6px right of the label; active is matched by label.\nconst props = defineProps({ tabs: { type: Array, default: () => [] }, active: String, size: String, showZero: Boolean, style: Object })\nconst emit = defineEmits(['change'])\nconst items = computed(() => props.tabs.map(t => typeof t === 'string' ? { label: t } : t))\n</script>\n<template>\n <div role=\"tablist\" :data-size=\"size\" :style=\"{ display: 'flex', gap: 'var(--control-gap)', ...style }\">\n <button v-for=\"t in items\" :key=\"t.label\" type=\"button\" role=\"tab\" :aria-selected=\"t.label === active\" :data-ink=\"t.label === active ? undefined : ''\" :data-wash=\"t.label === active ? undefined : ''\" @click=\"emit('change', t.label)\"\n :style=\"{ background: t.label === active ? 'var(--bg-active)' : 'var(--wash, transparent)', border: 'none', cursor: 'pointer', height: 'var(--control-h)', padding: '0 var(--control-px)', borderRadius: 'var(--radius-md)', display: 'inline-flex', alignItems: 'center', gap: '6px', fontFamily: 'var(--font-sans)', fontWeight: 'var(--weight-medium)', fontSize: 'var(--control-text)', lineHeight: 'var(--control-leading)', letterSpacing: 'var(--control-tracking)', color: t.label === active ? 'var(--text-heading)' : 'var(--ink, var(--text-secondary))', whiteSpace: 'nowrap' }\">{{ t.label }}<CountBadge :count=\"t.count\" :selected=\"t.label === active\" :show-zero=\"t.showZero ?? showZero\" /></button>\n </div>\n</template>\n","tabs/CountBadge.vue":"<script setup>\nimport { computed } from 'vue'\n// Count badge beside a segment / pill label: --count-h tall (sm 16 · md and lg 18), 6px sides, full radius, 12px tabular figures\n// (digits are Latin, so not bumped in CJK). Fill: the alpha wash --neutral-alpha-8, so it darkens any surface by the same step (white tab,\n// #f5f5f5 active pill, dark theme); text tells selected from unselected: --text-heading vs --text-body (a lighter grey fails 4.5:1 at 12px).\n// Hidden for 0 or a missing count unless showZero, which draws a muted 0.\nconst props = defineProps({ count: Number, selected: Boolean, showZero: Boolean, style: Object })\nconst zero = computed(() => props.count === 0 || props.count == null)\n</script>\n<template>\n <span v-if=\"!zero || showZero\" aria-hidden=\"true\" :style=\"{ display: 'inline-flex', alignItems: 'center', height: 'var(--count-h)', padding: '0 6px', borderRadius: 'var(--radius-full)', background: 'var(--neutral-alpha-8)', color: zero ? 'var(--text-muted)' : selected ? 'var(--text-heading)' : 'var(--text-body)', fontFamily: 'var(--font-sans)', fontSize: '12px', lineHeight: 'var(--count-h)', fontWeight: 'var(--weight-medium)', letterSpacing: 0, fontVariantNumeric: 'tabular-nums', flexShrink: 0, ...style }\">{{ zero ? 0 : count }}</span>\n</template>\n"});
|
|
@@ -5,8 +5,10 @@ export interface SmartTableField {
|
|
|
5
5
|
label: string;
|
|
6
6
|
/** Cell type: text | longText | number | select | multiSelect | date | person | checkbox | url | progress */
|
|
7
7
|
type?: string;
|
|
8
|
-
/**
|
|
8
|
+
/** Starting column width px (default 140); the user can drag the header edge to change it (see resizable) */
|
|
9
9
|
width?: number;
|
|
10
|
+
/** Lower bound while resizing (default 60) */
|
|
11
|
+
minWidth?: number;
|
|
10
12
|
/** select/multiSelect: option label → hue (gray|blue|green|orange|amber|teal|cyan|purple|pink|red) */
|
|
11
13
|
options?: Record<string, string>;
|
|
12
14
|
/** Footer aggregate: count | sum | avg */
|
|
@@ -32,6 +34,12 @@ export interface SmartTableProps {
|
|
|
32
34
|
onDensityChange?: (density: "default" | "compact") => void;
|
|
33
35
|
/** Raw row height; overrides density. Prefer density. */
|
|
34
36
|
rowHeight?: number | string;
|
|
37
|
+
/** Drag a field header's right edge to set that field's width (default true): 7px hit strip, col-resize cursor, on hover and while dragging the column's own grid line turns into a 2px --action-primary line. Pass false to fix the widths. */
|
|
38
|
+
resizable?: boolean;
|
|
39
|
+
/** Controlled widths by field key (px). Omit to let the grid keep them. */
|
|
40
|
+
columnWidths?: Record<string, number>;
|
|
41
|
+
/** Fires on every drag frame with the new px width. Vue: `@column-resize`. */
|
|
42
|
+
onColumnResize?: (key: string, width: number) => void;
|
|
35
43
|
/** Size tier of the grid region. Defaults to "sm" — a database grid is a dense surface (36px rows, 12px text, 28px toolbar buttons). */
|
|
36
44
|
size?: "sm" | "md" | "lg";
|
|
37
45
|
onChange?: (rows: object[]) => void;
|
|
@@ -12,9 +12,16 @@ const Pill = ({ hue = "gray", children }) => <span style={{ display: "inline-fle
|
|
|
12
12
|
/** Notion/Airtable-style database grid ("smart table"): field-type icons in headers, colored select pills, person/date/checkbox/url/progress cells, row hover + expand, inline text/number editing, + New row, add-field column, per-column footer aggregates, and a view toolbar (Hide fields · Filter · Group · Sort · search). */
|
|
13
13
|
/* A size region that defaults to sm: a database grid is a dense surface, so rows are the sm bar height (36), cells and toolbar read the sm control text (12px, 13px in CJK) and the toolbar's buttons are 28px. Pass size="md" for a roomier grid.
|
|
14
14
|
density: "default" rows are --bar-h (36 / 40 / 48) with --cell-px; "compact" rows are --control-h (28 / 32 / 40, the Spreadsheet's cell height) with the Spreadsheet's 8px padding (so at sm only the height changes). Header and footer follow. The toolbar's "Row height" menu switches it; rowHeight (a raw height) overrides density.
|
|
15
|
-
Frozen edges (like Spreadsheet): the field header row is sticky to the top and the 40px row-number column to the left; the corner cell is sticky on both axes. Sticky cells keep the --bg-inset fill so body cells never show through, and the frozen edge is a --border-subtle hairline.
|
|
15
|
+
Frozen edges (like Spreadsheet): the field header row is sticky to the top and the 40px row-number column to the left; the corner cell is sticky on both axes. Sticky cells keep the --bg-inset fill so body cells never show through, and the frozen edge is a --border-subtle hairline.
|
|
16
|
+
resizable (default true): drag a field header's right edge to set its width (min field.minWidth ?? 60); columnWidths / onColumnResize(key, px) control it. */
|
|
17
|
+
/* Column resize handle — a 7px hit strip inside the header cell's right edge (cursor col-resize). Hover or drag turns the cell's own right
|
|
18
|
+
grid line into a 2px --action-primary line (EDGE_LIT: the border plus a 1px inset shadow), so the line sits ON the column boundary. */
|
|
19
|
+
function ResizeHandle({ onMouseDown, onEnter, onLeave }) {
|
|
20
|
+
return <span aria-hidden="true" onMouseDown={onMouseDown} onMouseEnter={onEnter} onMouseLeave={onLeave} style={{ position: "absolute", top: 0, right: 0, width: 7, height: "100%", cursor: "col-resize", zIndex: 1 }}></span>;
|
|
21
|
+
}
|
|
22
|
+
const EDGE_LIT = { borderRight: "1px solid var(--action-primary)", boxShadow: "inset -1px 0 0 var(--action-primary)" }; // overrides the borderRight shorthand (a longhand would leave currentColor behind on leave)
|
|
16
23
|
const DENSITY = { default: { h: "var(--bar-h)", px: "var(--cell-px)" }, compact: { h: "var(--control-h)", px: "8px" } };
|
|
17
|
-
export function SmartTable({ fields = [], rows = [], title = "Grid view", toolbar = true, height, rowHeight, density: densityProp, defaultDensity = "default", onDensityChange, size = "sm", onChange, style }) {
|
|
24
|
+
export function SmartTable({ fields = [], rows = [], title = "Grid view", toolbar = true, height, rowHeight, density: densityProp, defaultDensity = "default", onDensityChange, resizable = true, columnWidths, onColumnResize, size = "sm", onChange, style }) {
|
|
18
25
|
const [data, setData] = React.useState(rows);
|
|
19
26
|
const [densityState, setDensityState] = React.useState(defaultDensity);
|
|
20
27
|
const density = DENSITY[densityProp || densityState] ? (densityProp || densityState) : "default";
|
|
@@ -24,7 +31,20 @@ export function SmartTable({ fields = [], rows = [], title = "Grid view", toolba
|
|
|
24
31
|
const [editing, setEditing] = React.useState(null);
|
|
25
32
|
const [draft, setDraft] = React.useState("");
|
|
26
33
|
const setCell = (r, key, v) => { const next = data.map((row, i) => i === r ? { ...row, [key]: v } : row); setData(next); onChange && onChange(next); };
|
|
27
|
-
|
|
34
|
+
// Column widths: local state seeded from field.width (140) unless columnWidths controls them; onColumnResize fires on every drag frame.
|
|
35
|
+
const [localW, setLocalW] = React.useState({});
|
|
36
|
+
const [rz, setRz] = React.useState({ hot: null, drag: null });
|
|
37
|
+
const widthOf = (f) => (columnWidths || localW)[f.key] ?? f.width ?? 140;
|
|
38
|
+
const startResize = (e, f) => {
|
|
39
|
+
e.preventDefault(); e.stopPropagation();
|
|
40
|
+
const x0 = e.clientX, w0 = widthOf(f), min = f.minWidth ?? 60;
|
|
41
|
+
setRz((s) => ({ ...s, drag: f.key }));
|
|
42
|
+
const move = (ev) => { const w = Math.max(min, Math.round(w0 + ev.clientX - x0)); if (!columnWidths) setLocalW((s) => ({ ...s, [f.key]: w })); if (onColumnResize) onColumnResize(f.key, w); };
|
|
43
|
+
document.body.style.cursor = "col-resize"; document.body.style.userSelect = "none";
|
|
44
|
+
const up = () => { setRz((s) => ({ ...s, drag: null })); window.removeEventListener("mousemove", move); window.removeEventListener("mouseup", up); document.body.style.cursor = ""; document.body.style.userSelect = ""; };
|
|
45
|
+
window.addEventListener("mousemove", move); window.addEventListener("mouseup", up);
|
|
46
|
+
};
|
|
47
|
+
const widths = ["40px", ...fields.map(f => widthOf(f) + "px"), "40px"];
|
|
28
48
|
const grid = { display: "grid", gridTemplateColumns: widths.join(" "), width: "fit-content", minWidth: "100%" };
|
|
29
49
|
const cellBase = { display: "flex", alignItems: "center", gap: 5, padding: "0 " + DENSITY[density].px, borderRight: "1px solid var(--border-subtle)", borderBottom: "1px solid var(--border-subtle)", height: rowH, boxSizing: "border-box", overflow: "hidden", whiteSpace: "nowrap", fontFamily: "var(--font-sans)", fontSize: "var(--control-text)", lineHeight: "var(--control-leading)", letterSpacing: "var(--control-tracking)", color: "var(--text-primary)" };
|
|
30
50
|
const tierIcon = { width: "var(--control-icon)", height: "var(--control-icon)" };
|
|
@@ -70,7 +90,7 @@ export function SmartTable({ fields = [], rows = [], title = "Grid view", toolba
|
|
|
70
90
|
<div style={{ overflow: "auto", maxHeight: height }}>
|
|
71
91
|
<div style={grid}>
|
|
72
92
|
<span style={{ ...cellBase, ...stickyCorner, justifyContent: "center" }}></span>
|
|
73
|
-
{fields.map(f => <span key={f.key} style={{ ...cellBase, ...stickyTop, color: "var(--text-secondary)", fontWeight: "var(--weight-semibold)" }}><Icon name={typeIcon[f.type] || "type"} size={14} style={{ ...tierIcon, color: "var(--text-muted)" }} />{f.label}</span>)}
|
|
93
|
+
{fields.map(f => <span key={f.key} style={{ ...cellBase, ...stickyTop, color: "var(--text-secondary)", fontWeight: "var(--weight-semibold)", ...(resizable && (rz.hot === f.key || rz.drag === f.key) ? EDGE_LIT : null) }}><Icon name={typeIcon[f.type] || "type"} size={14} style={{ ...tierIcon, color: "var(--text-muted)" }} />{f.label}{resizable && <ResizeHandle onMouseDown={(e) => startResize(e, f)} onEnter={() => setRz((s) => ({ ...s, hot: f.key }))} onLeave={() => setRz((s) => ({ ...s, hot: null }))} />}</span>)}
|
|
74
94
|
<span style={{ ...cellBase, ...stickyTop, justifyContent: "center", padding: 0 }}><IconButton icon="plus" muted label="Add field" /></span>
|
|
75
95
|
{data.map((row, r) => [
|
|
76
96
|
<span key={"n" + r} onMouseEnter={() => setHover(r)} onMouseLeave={() => setHover(-1)} style={{ ...cellBase, ...stickyLeft, justifyContent: "center", color: "var(--text-muted)", fontFamily: "var(--font-sans)", fontVariantNumeric: "tabular-nums", fontSize: "var(--text-xs)", letterSpacing: 0, cursor: "pointer" }}>{hover === r ? <Icon name="maximize-2" size={14} style={{ ...tierIcon, color: "var(--action-primary)" }} /> : r + 1}</span>,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
-
import { ref, computed } from 'vue'
|
|
2
|
+
import { ref, computed, reactive } from 'vue'
|
|
3
3
|
import Icon from '../icons/Icon.vue'
|
|
4
4
|
import IconButton from '../buttons/IconButton.vue'
|
|
5
5
|
import Checkbox from '../forms/Checkbox.vue'
|
|
@@ -8,7 +8,7 @@ import SearchInput from '../forms/SearchInput.vue'
|
|
|
8
8
|
import Menu from '../feedback/Menu.vue'
|
|
9
9
|
// Notion/Airtable-style database grid: field-type header icons, hue pills, person/date/checkbox/url/progress cells, row hover + expand, inline editing, + New row, footer aggregates, view toolbar.
|
|
10
10
|
const typeIcon = { text: 'type', longText: 'align-left', number: 'hash', select: 'chevron-down', multiSelect: 'tag', date: 'calendar', person: 'user', checkbox: 'check-square', url: 'link', progress: 'bar-chart-2' }
|
|
11
|
-
const props = defineProps({ fields: { type: Array, default: () => [] }, rows: { type: Array, default: () => [] }, title: { type: String, default: 'Grid view' }, toolbar: { type: Boolean, default: true }, height: [Number, String], rowHeight: [Number, String], density: String, defaultDensity: { type: String, default: 'default' }, size: { type: String, default: 'sm' }, style: Object })
|
|
11
|
+
const props = defineProps({ fields: { type: Array, default: () => [] }, rows: { type: Array, default: () => [] }, title: { type: String, default: 'Grid view' }, toolbar: { type: Boolean, default: true }, height: [Number, String], rowHeight: [Number, String], density: String, defaultDensity: { type: String, default: 'default' }, resizable: { type: Boolean, default: true }, columnWidths: Object, size: { type: String, default: 'sm' }, style: Object })
|
|
12
12
|
// A size region that defaults to sm: a database grid is a dense surface, so rows are the sm bar height (36), cells and toolbar read the sm control text (12px, 13px in CJK) and the toolbar's buttons are 28px. Pass size="md" for a roomier grid.
|
|
13
13
|
// density: 'default' rows are --bar-h (36 / 40 / 48) with --cell-px; 'compact' rows are --control-h (28 / 32 / 40, the Spreadsheet's cell height) with the Spreadsheet's 8px padding (so at sm only the height changes). Header and footer follow. The toolbar's "Row height" menu switches it (emits density-change; v-model:density to control); rowHeight (a raw height) overrides density.
|
|
14
14
|
// Frozen edges (like Spreadsheet): the field header row is sticky to the top and the 40px row-number column to the left; the corner cell is sticky on both axes. Sticky cells keep the --bg-inset fill so body cells never show through, and the frozen edge is a --border-subtle hairline.
|
|
@@ -18,7 +18,7 @@ const tierIcon = { width: 'var(--control-icon)', height: 'var(--control-icon)' }
|
|
|
18
18
|
const stickyTop = { position: 'sticky', top: 0, zIndex: 2, background: 'var(--bg-inset)' }
|
|
19
19
|
const stickyLeft = { position: 'sticky', left: 0, zIndex: 1, background: 'var(--bg-inset)' }
|
|
20
20
|
const stickyCorner = { ...stickyTop, left: 0, zIndex: 3 }
|
|
21
|
-
const emit = defineEmits(['change', 'update:density', 'densityChange'])
|
|
21
|
+
const emit = defineEmits(['change', 'update:density', 'densityChange', 'columnResize'])
|
|
22
22
|
const densityState = ref(props.defaultDensity)
|
|
23
23
|
const density = computed(() => DENSITY[props.density || densityState.value] ? (props.density || densityState.value) : 'default')
|
|
24
24
|
function setDensity(d) { if (!props.density) densityState.value = d; emit('update:density', d); emit('densityChange', d) }
|
|
@@ -26,7 +26,20 @@ const rowH = computed(() => props.rowHeight != null ? (typeof props.rowHeight ==
|
|
|
26
26
|
const cellPx = computed(() => DENSITY[density.value].px)
|
|
27
27
|
const data = ref(props.rows.map(r => ({ ...r })))
|
|
28
28
|
const hover = ref(-1), editing = ref(null), draft = ref('')
|
|
29
|
-
|
|
29
|
+
// resizable (default true): drag a field header's right edge to set its width (min field.minWidth ?? 60). Local unless columnWidths controls it; @column-resize(key, px) on every drag frame.
|
|
30
|
+
const localW = reactive({}), rz = reactive({ hot: null, drag: null })
|
|
31
|
+
const edgeLit = { borderRight: '1px solid var(--action-primary)', boxShadow: 'inset -1px 0 0 var(--action-primary)' } // hover / drag: the header's own right grid line turns 2px --action-primary, on the column boundary
|
|
32
|
+
const widthOf = (f) => (props.columnWidths || localW)[f.key] ?? f.width ?? 140
|
|
33
|
+
function startResize(e, f) {
|
|
34
|
+
e.preventDefault(); e.stopPropagation()
|
|
35
|
+
const x0 = e.clientX, w0 = widthOf(f), min = f.minWidth ?? 60
|
|
36
|
+
rz.drag = f.key
|
|
37
|
+
const move = (ev) => { const w = Math.max(min, Math.round(w0 + ev.clientX - x0)); if (!props.columnWidths) localW[f.key] = w; emit('columnResize', f.key, w) }
|
|
38
|
+
const up = () => { rz.drag = null; window.removeEventListener('mousemove', move); window.removeEventListener('mouseup', up); document.body.style.cursor = ''; document.body.style.userSelect = '' }
|
|
39
|
+
document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'
|
|
40
|
+
window.addEventListener('mousemove', move); window.addEventListener('mouseup', up)
|
|
41
|
+
}
|
|
42
|
+
const gridCols = computed(() => ['40px', ...props.fields.map(f => widthOf(f) + 'px'), '40px'].join(' '))
|
|
30
43
|
const cellBase = computed(() => ({ display: 'flex', alignItems: 'center', gap: '5px', padding: '0 ' + cellPx.value, borderRight: '1px solid var(--border-subtle)', borderBottom: '1px solid var(--border-subtle)', boxSizing: 'border-box', overflow: 'hidden', whiteSpace: 'nowrap', fontFamily: 'var(--font-sans)', fontSize: 'var(--control-text)', lineHeight: 'var(--control-leading)', letterSpacing: 'var(--control-tracking)', color: 'var(--text-primary)' }))
|
|
31
44
|
function setCell(r, key, v) { data.value[r][key] = v; emit('change', data.value) }
|
|
32
45
|
function commitEdit(r, f) { setCell(r, f.key, f.type === 'number' && draft.value !== '' && !isNaN(Number(draft.value)) ? Number(draft.value) : draft.value); editing.value = null }
|
|
@@ -54,7 +67,7 @@ function agg(f) {
|
|
|
54
67
|
<div :style="{ overflow: 'auto', maxHeight: typeof height === 'number' ? height + 'px' : height }">
|
|
55
68
|
<div :style="{ display: 'grid', gridTemplateColumns: gridCols, width: 'fit-content', minWidth: '100%' }">
|
|
56
69
|
<span :style="{ ...cellBase, height: rowH, ...stickyCorner }" />
|
|
57
|
-
<span v-for="f in fields" :key="f.key" :style="{ ...cellBase, height: rowH, ...stickyTop, color: 'var(--text-secondary)', fontWeight: 'var(--weight-semibold)' }"><Icon :name="typeIcon[f.type] || 'type'" :size="14" :style="{ ...tierIcon, color: 'var(--text-muted)' }" />{{ f.label }}
|
|
70
|
+
<span v-for="f in fields" :key="f.key" :style="{ ...cellBase, height: rowH, ...stickyTop, color: 'var(--text-secondary)', fontWeight: 'var(--weight-semibold)', ...((resizable && (rz.hot === f.key || rz.drag === f.key)) ? edgeLit : null) }"><Icon :name="typeIcon[f.type] || 'type'" :size="14" :style="{ ...tierIcon, color: 'var(--text-muted)' }" />{{ f.label }}<span v-if="resizable" aria-hidden="true" @mousedown="e => startResize(e, f)" @mouseenter="rz.hot = f.key" @mouseleave="rz.hot = null" style="position:absolute;top:0;right:0;width:7px;height:100%;cursor:col-resize;z-index:1" /></span>
|
|
58
71
|
<span :style="{ ...cellBase, height: rowH, ...stickyTop, justifyContent: 'center', padding: 0 }"><IconButton icon="plus" muted label="Add field" /></span>
|
|
59
72
|
<template v-for="(row, r) in data" :key="r">
|
|
60
73
|
<span @mouseenter="hover = r" @mouseleave="hover = -1" :style="{ ...cellBase, height: rowH, ...stickyLeft, justifyContent: 'center', color: 'var(--text-muted)', fontFamily: 'var(--font-sans)', fontVariantNumeric: 'tabular-nums', fontSize: 'var(--text-xs)', letterSpacing: 0, cursor: 'pointer' }">
|
|
@@ -18,7 +18,7 @@ export interface SpreadsheetProps {
|
|
|
18
18
|
data?: SpreadsheetCell[][];
|
|
19
19
|
/** Minimum grid size (padded with empty cells) */
|
|
20
20
|
rows?: number; cols?: number;
|
|
21
|
-
/**
|
|
21
|
+
/** Starting per-column pixel widths; default 92. The user can drag a column letter's right edge to change them (see resizable). */
|
|
22
22
|
colWidths?: number[]; defaultColWidth?: number;
|
|
23
23
|
/** Currency symbol for t:"currency" cells */
|
|
24
24
|
currency?: string;
|
|
@@ -30,6 +30,10 @@ export interface SpreadsheetProps {
|
|
|
30
30
|
height?: number | string;
|
|
31
31
|
/** Size tier of the sheet region. Defaults to "sm": a spreadsheet is the densest surface (24px toolbar controls, 12px text). */
|
|
32
32
|
size?: "sm" | "md" | "lg";
|
|
33
|
+
/** Drag a column header's right edge to resize that column (default true): 7px hit strip, col-resize cursor, on hover and while dragging the column's own grid line turns into a 2px --action-primary line; min 32px. Pass false to fix the widths. */
|
|
34
|
+
resizable?: boolean;
|
|
35
|
+
/** Fires on every drag frame: column index (0 = A) and the new px width. Vue: `@column-resize`. */
|
|
36
|
+
onColumnResize?: (col: number, width: number) => void;
|
|
33
37
|
onChange?: (cells: object[][]) => void;
|
|
34
38
|
style?: React.CSSProperties;
|
|
35
39
|
}
|
|
@@ -7,6 +7,14 @@ export const colName = (i) => { let s = ""; i++; while (i > 0) { s = String.from
|
|
|
7
7
|
const norm = (cell) => cell == null ? { v: "" } : (typeof cell === "object" ? { ...cell } : { v: cell });
|
|
8
8
|
const refRC = (ref) => { const m = /^([A-Z]+)(\d+)$/.exec(String(ref).trim().toUpperCase()); if (!m) return null; let c = 0; for (const ch of m[1]) c = c * 26 + (ch.charCodeAt(0) - 64); return { r: +m[2] - 1, c: c - 1 }; };
|
|
9
9
|
|
|
10
|
+
/* Column resize handle — a 7px hit strip inside the header cell's right edge (cursor col-resize). Hover or drag turns the cell's own right
|
|
11
|
+
grid line into a 2px --action-primary line (EDGE_LIT: the border plus a 1px inset shadow), so the line sits ON the column boundary. */
|
|
12
|
+
function ResizeHandle({ onMouseDown, onEnter, onLeave }) {
|
|
13
|
+
return <span aria-hidden="true" onMouseDown={onMouseDown} onMouseEnter={onEnter} onMouseLeave={onLeave} style={{ position: "absolute", top: 0, right: 0, width: 7, height: "100%", cursor: "col-resize", zIndex: 1 }}></span>;
|
|
14
|
+
}
|
|
15
|
+
const EDGE_LIT = { borderRight: "1px solid var(--action-primary)", boxShadow: "inset -1px 0 0 var(--action-primary)" }; // overrides the borderRight shorthand (a longhand would leave currentColor behind on leave)
|
|
16
|
+
const MIN_COL = 32;
|
|
17
|
+
|
|
10
18
|
function evalCell(cells, cell, depth = 0) {
|
|
11
19
|
if (!cell || depth > 12) return "";
|
|
12
20
|
const raw = cell.f != null ? cell.f : cell.v;
|
|
@@ -44,7 +52,7 @@ function fmt(val, t, currency) {
|
|
|
44
52
|
|
|
45
53
|
/** Excel-style spreadsheet: toolbar, formula bar (name box + fx), lettered/numbered frozen headers, click + shift-click range selection, double-click editing, =SUM()/AVERAGE()-style formulas, sheet tabs and a Sum/Avg/Count status bar. */
|
|
46
54
|
/* A size region that defaults to sm: a spreadsheet is the densest surface in the system — 24px toolbar controls, 12px text, 28px cells. */
|
|
47
|
-
export function Spreadsheet({ data = [], rows, cols, colWidths = [], defaultColWidth = 92, currency = "$", defaultActive = "A1", sheets = ["Sheet1"], activeSheet, toolbar = true, formulaBar = true, statusBar = true, height, size = "sm", onChange, style }) {
|
|
55
|
+
export function Spreadsheet({ data = [], rows, cols, colWidths = [], defaultColWidth = 92, currency = "$", defaultActive = "A1", sheets = ["Sheet1"], activeSheet, toolbar = true, formulaBar = true, statusBar = true, height, size = "sm", resizable = true, onColumnResize, onChange, style }) {
|
|
48
56
|
const nR = Math.max(rows || 0, data.length, 8), nC = Math.max(cols || 0, ...data.map(r => r.length), 6);
|
|
49
57
|
const [cells, setCells] = React.useState(() => Array.from({ length: nR }, (_, r) => Array.from({ length: nC }, (_, c) => norm(data[r] && data[r][c]))));
|
|
50
58
|
const a0 = refRC(defaultActive) || { r: 0, c: 0 };
|
|
@@ -63,7 +71,19 @@ export function Spreadsheet({ data = [], rows, cols, colWidths = [], defaultColW
|
|
|
63
71
|
const pick = (r, c, e) => { setActive({ r, c }); if (!(e && e.shiftKey)) setAnchor({ r, c }); setEditing(null); };
|
|
64
72
|
const setFlag = (k) => { const next = cells.map(row => row.map(cell => ({ ...cell }))); for (let r = lo.r; r <= hi.r; r++) for (let c = lo.c; c <= hi.c; c++) next[r][c][k] = !cur[k]; setCells(next); };
|
|
65
73
|
const setType = (label) => { const t = { Automatic: undefined, Number: "number", Currency: "currency", Percent: "percent" }[label]; const next = cells.map(row => row.map(cell => ({ ...cell }))); for (let r = lo.r; r <= hi.r; r++) for (let c = lo.c; c <= hi.c; c++) { if (t) next[r][c].t = t; else delete next[r][c].t; } setCells(next); };
|
|
66
|
-
|
|
74
|
+
// Column widths start from colWidths (defaultColWidth 92); resizable (default true) lets the user drag a column letter's right edge (min 32). onColumnResize(col, px) on every drag frame.
|
|
75
|
+
const [cw, setCw] = React.useState(() => Array.from({ length: nC }, (_, c) => colWidths[c] || defaultColWidth));
|
|
76
|
+
const [rz, setRz] = React.useState({ hot: null, drag: null });
|
|
77
|
+
const startResize = (e, c) => {
|
|
78
|
+
e.preventDefault(); e.stopPropagation();
|
|
79
|
+
const x0 = e.clientX, w0 = cw[c];
|
|
80
|
+
setRz((s) => ({ ...s, drag: c }));
|
|
81
|
+
const move = (ev) => { const w = Math.max(MIN_COL, Math.round(w0 + ev.clientX - x0)); setCw((s) => { const n = s.slice(); n[c] = w; return n; }); if (onColumnResize) onColumnResize(c, w); };
|
|
82
|
+
document.body.style.cursor = "col-resize"; document.body.style.userSelect = "none";
|
|
83
|
+
const up = () => { setRz((s) => ({ ...s, drag: null })); window.removeEventListener("mousemove", move); window.removeEventListener("mouseup", up); document.body.style.cursor = ""; document.body.style.userSelect = ""; };
|
|
84
|
+
window.addEventListener("mousemove", move); window.addEventListener("mouseup", up);
|
|
85
|
+
};
|
|
86
|
+
const widths = [46, ...cw];
|
|
67
87
|
const hdr = { position: "sticky", top: 0, zIndex: 3, background: "var(--bg-inset)", borderRight: "1px solid var(--border-control)", borderBottom: "1px solid var(--border-control)", display: "flex", alignItems: "center", justifyContent: "center", fontFamily: "var(--font-sans)", fontWeight: "var(--weight-medium)", fontSize: "var(--text-xs)", letterSpacing: "var(--tracking-xs)", color: "var(--text-secondary)", height: 26, boxSizing: "border-box", userSelect: "none" };
|
|
68
88
|
const tbBtn = (label, k, st) => <button key={label} type="button" data-wash={cur[k] ? undefined : ""} onClick={() => setFlag(k)} style={{ width: "var(--control-h)", height: "var(--control-h)", border: "none", borderRadius: "var(--radius-md)", cursor: "pointer", background: cur[k] ? "var(--bg-active)" : "var(--wash, transparent)", color: "var(--text-body)", fontFamily: "var(--font-sans)", fontSize: "var(--control-text)", ...st }}>{label}</button>;
|
|
69
89
|
return (
|
|
@@ -83,7 +103,7 @@ export function Spreadsheet({ data = [], rows, cols, colWidths = [], defaultColW
|
|
|
83
103
|
<div style={{ overflow: "auto", maxHeight: height }}>
|
|
84
104
|
<div style={{ display: "grid", gridTemplateColumns: widths.map(w => w + "px").join(" "), width: "fit-content" }}>
|
|
85
105
|
<span style={{ ...hdr, zIndex: 5, left: 0 }}><span style={{ width: 0, height: 0, borderStyle: "solid", borderWidth: "0 0 10px 10px", borderColor: "transparent transparent var(--border-control) transparent", alignSelf: "flex-end", marginLeft: "auto", marginRight: 3, marginBottom: 3 }}></span></span>
|
|
86
|
-
{Array.from({ length: nC }, (_, c) => <span key={c} style={{ ...hdr, ...(c >= lo.c && c <= hi.c ? { background: "var(--soft-bg)", color: "var(--action-primary)" } : null) }}>{colName(c)}</span>)}
|
|
106
|
+
{Array.from({ length: nC }, (_, c) => <span key={c} style={{ ...hdr, ...(c >= lo.c && c <= hi.c ? { background: "var(--soft-bg)", color: "var(--action-primary)" } : null), ...(resizable && (rz.hot === c || rz.drag === c) ? EDGE_LIT : null) }}>{colName(c)}{resizable && <ResizeHandle onMouseDown={(e) => startResize(e, c)} onEnter={() => setRz((s) => ({ ...s, hot: c }))} onLeave={() => setRz((s) => ({ ...s, hot: null }))} />}</span>)}
|
|
87
107
|
{cells.map((row, r) => [
|
|
88
108
|
<span key={"h" + r} style={{ ...hdr, position: "sticky", left: 0, top: "auto", zIndex: 2, height: 28, ...(r >= lo.r && r <= hi.r ? { background: "var(--soft-bg)", color: "var(--action-primary)" } : null) }}>{r + 1}</span>,
|
|
89
109
|
...row.map((cell, c) => {
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
-
import { ref, computed } from 'vue'
|
|
2
|
+
import { ref, computed, reactive } from 'vue'
|
|
3
3
|
import Icon from '../icons/Icon.vue'
|
|
4
4
|
import IconButton from '../buttons/IconButton.vue'
|
|
5
5
|
import KSelect from '../forms/Select.vue'
|
|
6
6
|
// Excel-style spreadsheet: toolbar, formula bar (name box + fx), frozen lettered/numbered headers, click + shift-click range selection, double-click editing, =SUM()/AVERAGE() formulas, sheet tabs, Sum/Avg/Count status bar.
|
|
7
|
-
const props = defineProps({ data: { type: Array, default: () => [] }, rows: Number, cols: Number, colWidths: { type: Array, default: () => [] }, defaultColWidth: { type: Number, default: 92 }, currency: { type: String, default: '$' }, defaultActive: { type: String, default: 'A1' }, sheets: { type: Array, default: () => ['Sheet1'] }, toolbar: { type: Boolean, default: true }, formulaBar: { type: Boolean, default: true }, statusBar: { type: Boolean, default: true }, height: [Number, String], size: { type: String, default: 'sm' }, style: Object })
|
|
7
|
+
const props = defineProps({ data: { type: Array, default: () => [] }, rows: Number, cols: Number, colWidths: { type: Array, default: () => [] }, defaultColWidth: { type: Number, default: 92 }, currency: { type: String, default: '$' }, defaultActive: { type: String, default: 'A1' }, sheets: { type: Array, default: () => ['Sheet1'] }, toolbar: { type: Boolean, default: true }, formulaBar: { type: Boolean, default: true }, statusBar: { type: Boolean, default: true }, height: [Number, String], size: { type: String, default: 'sm' }, resizable: { type: Boolean, default: true }, style: Object })
|
|
8
8
|
// A size region that defaults to sm: a spreadsheet is the densest surface in the system — 24px toolbar controls, 12px text, 28px cells.
|
|
9
|
-
const emit = defineEmits(['change'])
|
|
9
|
+
const emit = defineEmits(['change', 'columnResize'])
|
|
10
10
|
|
|
11
11
|
const colName = (i) => { let s = ''; i++; while (i > 0) { s = String.fromCharCode(64 + ((i - 1) % 26 + 1)) + s; i = Math.floor((i - 1) / 26) } return s }
|
|
12
12
|
const refRC = (ref) => { const m = /^([A-Z]+)(\d+)$/.exec(String(ref).trim().toUpperCase()); if (!m) return null; let c = 0; for (const ch of m[1]) c = c * 26 + (ch.charCodeAt(0) - 64); return { r: +m[2] - 1, c: c - 1 } }
|
|
@@ -50,7 +50,20 @@ function pick(r, c, e) { active.value = { r, c }; if (!(e && e.shiftKey)) anchor
|
|
|
50
50
|
function commit(r, c, text) { const cell = cells.value[r][c]; delete cell.f; if (String(text).startsWith('=')) { cell.f = text; cell.v = '' } else cell.v = text !== '' && !isNaN(Number(text)) ? Number(text) : text; emit('change', cells.value) }
|
|
51
51
|
function setFlag(k) { const on = !cur.value[k]; for (let r = lo.value.r; r <= hi.value.r; r++) for (let c = lo.value.c; c <= hi.value.c; c++) cells.value[r][c][k] = on }
|
|
52
52
|
function setType(label) { const t = { Automatic: undefined, Number: 'number', Currency: 'currency', Percent: 'percent' }[label]; for (let r = lo.value.r; r <= hi.value.r; r++) for (let c = lo.value.c; c <= hi.value.c; c++) { if (t) cells.value[r][c].t = t; else delete cells.value[r][c].t } }
|
|
53
|
-
|
|
53
|
+
// Column widths start from colWidths (defaultColWidth 92); resizable (default true) lets the user drag a column letter's right edge (min 32). @column-resize(col, px) on every drag frame.
|
|
54
|
+
const cw = ref(Array.from({ length: nC }, (_, c) => props.colWidths[c] || props.defaultColWidth))
|
|
55
|
+
const rz = reactive({ hot: null, drag: null })
|
|
56
|
+
const edgeLit = { borderRight: '1px solid var(--action-primary)', boxShadow: 'inset -1px 0 0 var(--action-primary)' } // hover / drag: the header's own right grid line turns 2px --action-primary, on the column boundary
|
|
57
|
+
function startResize(e, c) {
|
|
58
|
+
e.preventDefault(); e.stopPropagation()
|
|
59
|
+
const x0 = e.clientX, w0 = cw.value[c]
|
|
60
|
+
rz.drag = c
|
|
61
|
+
const move = (ev) => { const w = Math.max(32, Math.round(w0 + ev.clientX - x0)); cw.value[c] = w; emit('columnResize', c, w) }
|
|
62
|
+
const up = () => { rz.drag = null; window.removeEventListener('mousemove', move); window.removeEventListener('mouseup', up); document.body.style.cursor = ''; document.body.style.userSelect = '' }
|
|
63
|
+
document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'
|
|
64
|
+
window.addEventListener('mousemove', move); window.addEventListener('mouseup', up)
|
|
65
|
+
}
|
|
66
|
+
const gridCols = computed(() => ['46px', ...cw.value.map(w => w + 'px')].join(' '))
|
|
54
67
|
const hdr = { position: 'sticky', top: 0, zIndex: 3, background: 'var(--bg-inset)', borderRight: '1px solid var(--border-control)', borderBottom: '1px solid var(--border-control)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontFamily: 'var(--font-sans)', fontWeight: 'var(--weight-medium)', fontSize: 'var(--text-xs)', letterSpacing: 'var(--tracking-xs)', color: 'var(--text-secondary)', height: '26px', boxSizing: 'border-box', userSelect: 'none' }
|
|
55
68
|
const hl = { background: 'var(--soft-bg)', color: 'var(--action-primary)' }
|
|
56
69
|
</script>
|
|
@@ -73,7 +86,7 @@ const hl = { background: 'var(--soft-bg)', color: 'var(--action-primary)' }
|
|
|
73
86
|
<div :style="{ overflow: 'auto', maxHeight: typeof height === 'number' ? height + 'px' : height }">
|
|
74
87
|
<div :style="{ display: 'grid', gridTemplateColumns: gridCols, width: 'fit-content' }">
|
|
75
88
|
<span :style="{ ...hdr, zIndex: 5, left: 0 }" />
|
|
76
|
-
<span v-for="c in nC" :key="'c' + c" :style="{ ...hdr, ...((c - 1) >= lo.c && (c - 1) <= hi.c ? hl : null) }">{{ colName(c - 1) }}
|
|
89
|
+
<span v-for="c in nC" :key="'c' + c" :style="{ ...hdr, ...((c - 1) >= lo.c && (c - 1) <= hi.c ? hl : null), ...((resizable && (rz.hot === c - 1 || rz.drag === c - 1)) ? edgeLit : null) }">{{ colName(c - 1) }}<span v-if="resizable" aria-hidden="true" @mousedown="e => startResize(e, c - 1)" @mouseenter="rz.hot = c - 1" @mouseleave="rz.hot = null" style="position:absolute;top:0;right:0;width:7px;height:100%;cursor:col-resize;z-index:1" /></span>
|
|
77
90
|
<template v-for="(row, r) in cells" :key="r">
|
|
78
91
|
<span :style="{ ...hdr, position: 'sticky', left: 0, top: 'auto', zIndex: 2, height: '28px', ...(r >= lo.r && r <= hi.r ? hl : null) }">{{ r + 1 }}</span>
|
|
79
92
|
<span v-for="(cell, c) in row" :key="r + ',' + c" @mousedown="pick(r, c, $event)" @dblclick="editing = { r, c }; draft = rawOf(cell)"
|
|
@@ -25,7 +25,7 @@ export interface DataTableTwoLine {
|
|
|
25
25
|
more?: boolean;
|
|
26
26
|
}
|
|
27
27
|
export interface DataTableSort { key: string; direction: "asc" | "desc"; }
|
|
28
|
-
export interface DataTableLabels { sortAsc?: string; sortDesc?: string; sortClear?: string; more?: string; expand?: string; collapse?: string; }
|
|
28
|
+
export interface DataTableLabels { sortAsc?: string; sortDesc?: string; sortClear?: string; more?: string; expand?: string; collapse?: string; /** pinSelected="labels" group rows (default "Selected", "Others", "Unavailable", "Clear") */ pinned?: string; others?: string; unavailable?: string; clear?: string; }
|
|
29
29
|
export interface DataTableColumn {
|
|
30
30
|
key: string;
|
|
31
31
|
label: string;
|
|
@@ -34,7 +34,7 @@ export interface DataTableColumn {
|
|
|
34
34
|
minWidth?: number;
|
|
35
35
|
/** Tabular figures for amounts and IDs (UI font, the row's size and colour). */
|
|
36
36
|
mono?: boolean;
|
|
37
|
-
/** Config code (ALL_CAPS_SNAKE) shown as a code chip: Menlo --text-code (12px, 11px in sm) on --bg-
|
|
37
|
+
/** Config code (ALL_CAPS_SNAKE) shown as a code chip: Menlo --text-code (12px, 11px in sm) on --bg-hover with a --border-control edge. */
|
|
38
38
|
code?: boolean;
|
|
39
39
|
/** Two-line title cell (see DataTableTwoLine); `true` reads icon/hue/description/mark from the row. Give the column a width; when the table is tight it shrinks to minWidth (default 180), and text truncates to whatever width it gets. */
|
|
40
40
|
twoLine?: boolean | DataTableTwoLine;
|
|
@@ -43,6 +43,11 @@ export interface DataTableColumn {
|
|
|
43
43
|
/** Custom comparison for this column (ascending order). Default: numbers (commas allowed) numerically, text by locale with numeric collation, empty values last. */
|
|
44
44
|
compare?: (a: any, b: any, rowA: any, rowB: any) => number;
|
|
45
45
|
align?: "left" | "right" | "center";
|
|
46
|
+
/**
|
|
47
|
+
* Any component in the cell. Controls (Select, Input, DatePicker, NumberStepper, Button, IconButton, Switch, Checkbox, Radio, Tag) inherit the table's tier — never pass them `size`.
|
|
48
|
+
* Parts sized by a number do not follow the tier: Avatar / AvatarGroup size sm 20 · md 24 · lg 28; Sparkline sm 56×16 · md 64×20 · lg 80×24.
|
|
49
|
+
* Fixed at every tier: Badge (20px status pill) and the 6px ProgressBar track. Give editable controls `width: 100%` so they fill the column.
|
|
50
|
+
*/
|
|
46
51
|
render?: (value: any, row: any, index: number) => React.ReactNode;
|
|
47
52
|
}
|
|
48
53
|
export interface DataTableProps {
|
|
@@ -55,8 +60,23 @@ export interface DataTableProps {
|
|
|
55
60
|
/** Leading checkbox column */
|
|
56
61
|
selectable?: boolean;
|
|
57
62
|
selected?: number[];
|
|
58
|
-
/** onToggle("all"|rowIndex, checked)
|
|
59
|
-
|
|
63
|
+
/** onToggle("all"|rowIndex, checked, next). For "all" (header checkbox, or Clear in pinSelected="labels"), `next` is the selection afterwards:
|
|
64
|
+
* every unlocked row checked or unchecked, locked rows (isRowDisabled) as they were. Use `next` whenever isRowDisabled is set. */
|
|
65
|
+
onToggle?: (which: number | "all", checked: boolean, next?: number[]) => void;
|
|
66
|
+
/** With selectable: the app's lock for a row — e.g. "only one Version" locks the other Versions once one is picked. Called on every render, never cached, so it may read `selected`.
|
|
67
|
+
* A locked row's checkbox is disabled (50% opacity, not-allowed cursor, no hover; clicks never call onToggle) and the row takes no hover wash; the cells are left as the app renders them.
|
|
68
|
+
* The lock blocks checking only: it never changes `selected`, so a locked row that is checked stays checked. The header checkbox counts unlocked rows only;
|
|
69
|
+
* Select-all / Clear leave locked rows alone. With pinSelected, a locked unchecked row stays in the unchecked group. */
|
|
70
|
+
isRowDisabled?: (row: any, index: number) => boolean;
|
|
71
|
+
/** Tooltip on a locked row's checkbox, e.g. "Version slot already filled". */
|
|
72
|
+
disabledReason?: (row: any, index: number) => string;
|
|
73
|
+
/** With selectable: checked rows move to the top (of their level in a tree), in the current sort order; unchecking returns a row to its place. Indices in `selected` and `onToggle` stay the original row positions.
|
|
74
|
+
* Pinned rows sit on --bg-inset; while any are pinned, the header rule and the rule closing the group are --border-control (#e5e5e5). `true` | "divider": just that. "labels": a 28px group row above each group on the header tint —
|
|
75
|
+
* "Selected · n" with a Clear link (fires onToggle("all", false)) and "Others · n"; translate with labels.pinned / others / clear.
|
|
76
|
+
* In "labels" the group rows' rules are --border-control as well, so header, groups and the pinned band stay apart.
|
|
77
|
+
* With isRowDisabled, "labels" adds a third group after Others for locked unchecked rows — "Unavailable · n" (labels.unavailable). The group order stays
|
|
78
|
+
* Selected · Others · Unavailable under sorting (the sort applies inside each group), rows move between groups as their lock changes, and an empty group shows no group row. */
|
|
79
|
+
pinSelected?: boolean | "divider" | "labels";
|
|
60
80
|
headerBorder?: boolean;
|
|
61
81
|
/** Draws its own rounded card frame (border-card, radius-lg) and drops the header-top and last-row rules that would double it. Use instead of wrapping the table in a bordered div. */
|
|
62
82
|
bordered?: boolean;
|
|
@@ -67,6 +87,7 @@ export interface DataTableProps {
|
|
|
67
87
|
/** Controlled widths by column key (px). Omit to let the table keep them locally. */
|
|
68
88
|
columnWidths?: Record<string, number>;
|
|
69
89
|
/** Fires on every drag frame with the new px width. Vue: `@column-resize`. */
|
|
90
|
+
/** Fires on every drag with the new width. Without columnWidths the table also keeps the width itself; with columnWidths, update it from here. */
|
|
70
91
|
onColumnResize?: (key: string, width: number) => void;
|
|
71
92
|
/** ⋯ in a twoLine cell was clicked. Vue: `@row-more`. */
|
|
72
93
|
onRowMore?: (row: any, index: number, event: React.MouseEvent) => void;
|