diff --git a/demo/index.html b/demo/index.html index 33e0b39..d7c67f7 100644 --- a/demo/index.html +++ b/demo/index.html @@ -14,6 +14,8 @@

VMK UI Kit

Demo page placeholder. Component sections will be added here.

+ + @@include('./partials/buttons.html')
diff --git a/demo/partials/buttons.html b/demo/partials/buttons.html new file mode 100644 index 0000000..8f80f25 --- /dev/null +++ b/demo/partials/buttons.html @@ -0,0 +1,82 @@ +
+

Buttons

+ +

Variants

+
+ + + + + + + + + +
+ +

Sizes

+
+ + + + + +
+ +

With icon

+
+ + + +
+ +

Icon only

+
+ + + +
+ +

States

+
+ + + +
+
+ + + +
+ +

Destructive variants

+
+ + + +
+
diff --git a/examples/vue/src/main.js b/examples/vue/src/main.js index d7cf8b8..a5ffd93 100644 --- a/examples/vue/src/main.js +++ b/examples/vue/src/main.js @@ -6,12 +6,18 @@ const App = { setup() { const count = ref(0); - return { count }; + const loading = ref(false); + return { count, loading }; }, template: `

VMK UI Kit Vue Example

-

Placeholder: adapter components will be wired here.

+
+ Count: {{ count }} + Phosphor name + Toggle loading + Disabled +
` }; diff --git a/src/scss/components/_buttons.scss b/src/scss/components/_buttons.scss new file mode 100644 index 0000000..d980c27 --- /dev/null +++ b/src/scss/components/_buttons.scss @@ -0,0 +1,280 @@ +/* ========================= + Buttons + Figma: Buttons / Button — Large 64, Medium 48, Small 40, + Xsmall 32, XXsmall 24. +========================= */ + +@use "sass:color"; +@use "../kit-deps" as *; + +@keyframes vmk-spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +.btn { + // Variant tokens + --btn-bg: transparent; + --btn-border: transparent; + --btn-text: #{$neutral-900}; + --btn-hover-bg: var(--btn-bg); + --btn-hover-border: var(--btn-border); + --btn-hover-text: var(--btn-text); + --btn-focus-bg: var(--btn-hover-bg); + --btn-focus-border: var(--btn-hover-border); + --btn-focus-text: var(--btn-hover-text); + --btn-disabled-bg: var(--btn-bg); + --btn-disabled-border: var(--btn-border); + --btn-disabled-text: var(--btn-text); + + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + gap: $space-2; + font-family: $font-family-base; + font-weight: $font-weight-semibold; + line-height: 1; + text-align: center; + text-decoration: none; + text-transform: none; + white-space: nowrap; + vertical-align: middle; + border-width: $border-width-base; + border-style: $border-style-base; + border-color: var(--btn-border); + border-radius: $radius-full; + background-color: var(--btn-bg); + color: var(--btn-text); + cursor: pointer; + transition: + background-color $motion-base $motion-ease, + border-color $motion-base $motion-ease, + color $motion-base $motion-ease, + opacity $motion-base $motion-ease, + box-shadow $motion-base $motion-ease; + + &:focus-visible { + @include focus_ring; + } + + &:hover:not(:disabled):not(.is-disabled):not(.is-loading) { + background-color: var(--btn-hover-bg); + border-color: var(--btn-hover-border); + color: var(--btn-hover-text); + } + + &:active:not(:disabled):not(.is-disabled):not(.is-loading) { + background-color: var(--btn-focus-bg); + border-color: var(--btn-focus-border); + color: var(--btn-focus-text); + } + + &:disabled, + &.is-disabled { + --btn-bg: var(--btn-disabled-bg) !important; + --btn-border: var(--btn-disabled-border) !important; + --btn-text: var(--btn-disabled-text) !important; + cursor: not-allowed; + opacity: 0.72; + } + + &.is-loading { + cursor: wait; + + .vmk-icon { + animation: vmk-spin 1s linear infinite; + } + } + + // Make the spinner inherit the current token color even when a variant + // overrides it mid-flight. + .vmk-icon { + color: currentColor; + flex-shrink: 0; + } +} + +// Sizes +// Reference API: sm (40), md (48), lg (64). Figma adds xs (32) and xxs (24). +.btn-lg { + min-height: 64px; + padding: $space-4 $space-6; + font-size: 16px; +} + +.btn-md { + min-height: 48px; + padding: $space-3 $space-5; + font-size: 15px; +} + +.btn-sm { + min-height: 40px; + padding: $space-2 $space-4; + font-size: 13px; +} + +.btn-xs { + min-height: 32px; + padding: $space-1 $space-3; + font-size: 12px; +} + +.btn-xxs { + min-height: 24px; + padding: $space-0 $space-2; + font-size: 13px; +} + +// Block button +.btn-block { + display: flex; + width: 100%; +} + +// Icon-only: keep a perfect circle and equal padding. +.btn-icon-only { + aspect-ratio: 1 / 1; + padding: 0; + + &.btn-lg { width: 64px; } + &.btn-md { width: 48px; } + &.btn-sm { width: 40px; } + &.btn-xs { width: 32px; } + &.btn-xxs { width: 24px; } +} + +// Variants +// Primary — filled yellow (Figma default) +.btn-primary { + --btn-bg: #{$yellow-300}; + --btn-border: #{$yellow-300}; + --btn-text: #{$neutral-900}; + --btn-hover-bg: #{$yellow-400}; + --btn-hover-border: #{$yellow-400}; + --btn-focus-bg: #{$yellow-400}; + --btn-focus-border: #{$yellow-400}; + --btn-disabled-bg: #{$yellow-100}; + --btn-disabled-border: #{$yellow-100}; + --btn-disabled-text: #{$neutral-500}; +} + +// Accent maps to the same yellow surface as primary for compatibility with +// gnexus-ui-kit projects that used accent for the brand highlight. +.btn-accent { + @extend .btn-primary; +} + +// Secondary — white fill + mint border (Figma) +.btn-secondary { + --btn-bg: #{$neutral-0}; + --btn-border: #{$mint-600}; + --btn-text: #{$mint-800}; + --btn-hover-bg: #{$mint-50}; + --btn-hover-border: #{$mint-600}; + --btn-focus-bg: #{$mint-100}; + --btn-focus-border: #{$mint-600}; + --btn-disabled-bg: #{$mint-50}; + --btn-disabled-border: #{$neutral-500}; + --btn-disabled-text: #{$neutral-500}; +} + +// Tertiary — transparent mint text (Figma) +.btn-tertiary { + --btn-bg: transparent; + --btn-border: transparent; + --btn-text: #{$mint-900}; + --btn-hover-bg: #{rgba($mint-50, 0.64)}; + --btn-hover-border: transparent; + --btn-focus-bg: #{$mint-50}; + --btn-focus-border: transparent; + --btn-disabled-bg: transparent; + --btn-disabled-border: transparent; + --btn-disabled-text: #{$neutral-500}; + + // Figma: tertiary icon-only buttons use a smaller radius than the pill shape. + &.btn-icon-only { + border-radius: $radius-md; + } +} + +// Semantic variants — use the primitive alert colors so existing projects +// using success / warning / info keep working. +.btn-success { + --btn-bg: #{$success-bg}; + --btn-border: #{$success-text}; + --btn-text: #{$success-text}; + --btn-hover-bg: #{rgba($success-bg, 0.8)}; + --btn-hover-border: #{$success-text}; + --btn-focus-bg: #{rgba($success-bg, 0.64)}; + --btn-disabled-bg: #{$success-bg}; + --btn-disabled-border: #{rgba($success-text, 0.4)}; + --btn-disabled-text: #{rgba($success-text, 0.5)}; +} + +.btn-warning { + --btn-bg: #{$warning-bg}; + --btn-border: #{$warning-text}; + --btn-text: #{$warning-text}; + --btn-hover-bg: #{rgba($warning-bg, 0.8)}; + --btn-hover-border: #{$warning-text}; + --btn-focus-bg: #{rgba($warning-bg, 0.64)}; + --btn-disabled-bg: #{$warning-bg}; + --btn-disabled-border: #{rgba($warning-text, 0.4)}; + --btn-disabled-text: #{rgba($warning-text, 0.5)}; +} + +.btn-info { + --btn-bg: #{$info-bg}; + --btn-border: #{$info-text}; + --btn-text: #{$info-text}; + --btn-hover-bg: #{rgba($info-bg, 0.8)}; + --btn-hover-border: #{$info-text}; + --btn-focus-bg: #{rgba($info-bg, 0.64)}; + --btn-disabled-bg: #{$info-bg}; + --btn-disabled-border: #{rgba($info-text, 0.4)}; + --btn-disabled-text: #{rgba($info-text, 0.5)}; +} + +// Danger / Error — destructive red fill (Figma destructive primary) +.btn-danger, +.btn-error { + --btn-bg: #{$danger-text}; + --btn-border: #{$danger-text}; + --btn-text: #{$neutral-0}; + --btn-hover-bg: #{color.adjust($danger-text, $lightness: -8%)}; + --btn-hover-border: #{color.adjust($danger-text, $lightness: -8%)}; + --btn-focus-bg: #{color.adjust($danger-text, $lightness: -12%)}; + --btn-focus-border: #{color.adjust($danger-text, $lightness: -12%)}; + --btn-disabled-bg: #{rgba($danger-text, 0.4)}; + --btn-disabled-border: #{rgba($danger-text, 0.4)}; + --btn-disabled-text: #{$neutral-0}; +} + +// Destructive secondary and tertiary variants for richer Figma coverage. +.btn-danger-secondary, +.btn-error-secondary { + --btn-bg: #{$danger-bg}; + --btn-border: #{$danger-text}; + --btn-text: #{$danger-text}; + --btn-hover-bg: #{color.adjust($danger-bg, $lightness: -4%)}; + --btn-hover-border: #{$danger-text}; + --btn-focus-bg: #{color.adjust($danger-bg, $lightness: -8%)}; + --btn-disabled-bg: #{$danger-bg}; + --btn-disabled-border: #{rgba($danger-text, 0.4)}; + --btn-disabled-text: #{rgba($danger-text, 0.5)}; +} + +.btn-danger-tertiary, +.btn-error-tertiary { + --btn-bg: transparent; + --btn-border: transparent; + --btn-text: #{$danger-text}; + --btn-hover-bg: #{$danger-bg}; + --btn-hover-border: transparent; + --btn-focus-bg: #{color.adjust($danger-bg, $lightness: -4%)}; + --btn-disabled-bg: transparent; + --btn-disabled-border: transparent; + --btn-disabled-text: #{rgba($danger-text, 0.5)}; +} diff --git a/src/scss/demo.scss b/src/scss/demo.scss index 55367eb..d5b5f34 100644 --- a/src/scss/demo.scss +++ b/src/scss/demo.scss @@ -10,3 +10,29 @@ margin: 0 auto; padding: $space-6; } + +.demo-section { + margin-bottom: $space-8; + + h2 { + font-size: 28px; + font-weight: $font-weight-bold; + margin-bottom: $space-4; + } + + h3 { + font-size: 18px; + font-weight: $font-weight-semibold; + margin-top: $space-6; + margin-bottom: $space-3; + color: $neutral-700; + } +} + +.demo-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: $space-3; + margin-bottom: $space-3; +} diff --git a/src/scss/kit.scss b/src/scss/kit.scss index bdd8036..92d86d1 100644 --- a/src/scss/kit.scss +++ b/src/scss/kit.scss @@ -2,6 +2,7 @@ @use "kit-deps" as *; @use "motion"; @use "components/icons"; +@use "components/buttons"; @use "utils"; * { diff --git a/src/vue/components/GnButton.js b/src/vue/components/GnButton.js new file mode 100644 index 0000000..09b44e3 --- /dev/null +++ b/src/vue/components/GnButton.js @@ -0,0 +1,56 @@ +/** + * GnButton — primary command component. + * + * API-compatible with gnexus-ui-kit GnButton. + * + * @typedef {Object} GnButtonProps + * @property {string} [variant='primary'] - primary | secondary | accent | success | warning | danger | error | info + * @property {string} [size='md'] - xxs | xs | sm | md | lg + * @property {string} [icon=''] - VMK icon key (e.g. "essentials/plus") or legacy Phosphor name with ph- prefix + * @property {boolean} [loading=false] - Show spinner and disable interaction + * @property {boolean} [disabled=false] - Disabled state + * @property {string} [type='button'] - button | submit | reset + * + * @slots default - Button label text + */ + +import { defineComponent, h } from "vue"; +import { cx, iconNode, normalizeVariant, normalizeSize } from "../utils.js"; + +export default defineComponent({ + name: "GnButton", + inheritAttrs: false, + props: { + variant: { type: String, default: "primary" }, + size: { type: String, default: "md" }, + icon: { type: String, default: "" }, + loading: { type: Boolean, default: false }, + disabled: { type: Boolean, default: false }, + type: { type: String, default: "button" } + }, + setup(props, { attrs, slots }) { + return () => { + const hasIcon = Boolean(props.icon || props.loading); + const variant = normalizeVariant(props.variant); + + return h("button", { + ...attrs, + type: props.type, + disabled: props.disabled || props.loading, + class: cx( + "btn", + `btn-${variant}`, + normalizeSize(props.size), + { + "with-icon": hasIcon, + "is-loading": props.loading + }, + attrs.class + ) + }, [ + props.loading ? iconNode("essentials/loading") : iconNode(props.icon), + slots.default?.() + ]); + }; + } +}); diff --git a/src/vue/components/Icon.js b/src/vue/components/Icon.js new file mode 100644 index 0000000..7598706 --- /dev/null +++ b/src/vue/components/Icon.js @@ -0,0 +1,42 @@ +/** + * VmkIcon — async inline SVG icon renderer. + * + * Loads icons via the shared icon helper and renders them as inline SVG. + * The icon name is a VMK key such as "essentials/plus" or "arrows/arrow/right". + */ + +import { defineComponent, h, onMounted, ref, watch } from "vue"; +import { iconSvg } from "../../js/components/icon-helper.js"; + +export default defineComponent({ + name: "VmkIcon", + inheritAttrs: false, + props: { + name: { type: String, default: "" }, + spin: { type: Boolean, default: false } + }, + setup(props, { attrs }) { + const svg = ref(""); + + const load = async () => { + if (!props.name) { + svg.value = ""; + return; + } + svg.value = await iconSvg(props.name); + }; + + onMounted(load); + watch(() => props.name, load); + + return () => { + if (!props.name) return null; + return h("span", { + ...attrs, + class: ["vmk-icon", { "is-spinning": props.spin }, attrs.class], + innerHTML: svg.value, + "aria-hidden": "true" + }); + }; + } +}); diff --git a/src/vue/index.js b/src/vue/index.js index 1bf0756..e5d49ed 100644 --- a/src/vue/index.js +++ b/src/vue/index.js @@ -3,4 +3,6 @@ * Exports the same component names and composables as gnexus-ui-kit/vue. */ +export { default as GnButton } from "./components/GnButton.js"; +export { default as VmkIcon } from "./components/Icon.js"; export { components, default as VMKUiVue } from "./plugin.js"; diff --git a/src/vue/plugin.js b/src/vue/plugin.js index a0437bb..36547ee 100644 --- a/src/vue/plugin.js +++ b/src/vue/plugin.js @@ -3,12 +3,18 @@ * Mirrors the GnexusUiVue plugin from gnexus-ui-kit. */ -export const components = {}; +import GnButton from "./components/GnButton.js"; +import VmkIcon from "./components/Icon.js"; + +export const components = { + GnButton, + VmkIcon +}; export default { - install(app) { - Object.entries(components).forEach(([name, component]) => { - app.component(name, component); - }); - } + install(app) { + Object.entries(components).forEach(([name, component]) => { + app.component(name, component); + }); + } }; diff --git a/src/vue/utils.js b/src/vue/utils.js new file mode 100644 index 0000000..6a56cc0 --- /dev/null +++ b/src/vue/utils.js @@ -0,0 +1,105 @@ +import { h } from "vue"; +import VmkIcon from "./components/Icon.js"; + +export const variants = new Set([ + "primary", + "secondary", + "accent", + "success", + "warning", + "danger", + "error", + "info" +]); + +export function cx(...items) { + return items + .flatMap(item => { + if (!item) { + return []; + } + + if (Array.isArray(item)) { + return item; + } + + if (typeof item === "object") { + return Object.entries(item) + .filter(([, enabled]) => enabled) + .map(([name]) => name); + } + + return [item]; + }) + .filter(Boolean) + .join(" "); +} + +export function normalizeVariant(value, fallback = "primary") { + return variants.has(value) ? value : fallback; +} + +const SIZE_MAP = { + xxs: "btn-xxs", + xs: "btn-xs", + sm: "btn-sm", + md: "btn-md", + lg: "btn-lg" +}; + +export function normalizeSize(value, fallback = "btn-md") { + return SIZE_MAP[value] || fallback; +} + +const PHOSPHOR_TO_VMK = { + spinner: "essentials/loading", + loader: "essentials/loading", + loading: "essentials/loading", + "arrow-right": "arrows/arrow/right", + "arrow-left": "arrows/arrow/left", + "arrow-up": "arrows/arrow/up", + "arrow-down": "arrows/arrow/down", + "chevron-right": "arrows/chevron/right", + "chevron-left": "arrows/chevron/left", + "chevron-up": "arrows/chevron/up", + "chevron-down": "arrows/chevron/down", + plus: "essentials/plus", + minus: "essentials/minus", + x: "essentials/close-cross", + close: "essentials/close-cross", + check: "essentials/check", + user: "essentials/user", + trash: "essentials/trash", + information: "essentials/information/circle", + info: "essentials/information/circle", + alert: "essentials/alert-sign", + warning: "essentials/alert-sign", + danger: "essentials/alert/circle", + error: "essentials/alert/circle", + success: "essentials/check/circle" +}; + +const PHOSPHOR_PREFIX_RE = /^ph[-\s]/; + +export function iconNode(icon, extraClass = "") { + if (!icon) { + return null; + } + + let name = icon.trim(); + + // Accept legacy Phosphor prefixes (ph-plus, ph bold ph-spinner, etc). + if (PHOSPHOR_PREFIX_RE.test(name)) { + name = name.slice(3).trim(); + } + + // If the name already looks like a VMK key, use it directly. + if (!name.includes("/")) { + name = PHOSPHOR_TO_VMK[name] || `essentials/${name}`; + } + + return h(VmkIcon, { + name, + class: extraClass + }); +}