diff --git a/demo/index.html b/demo/index.html index cf677d7..5b02898 100644 --- a/demo/index.html +++ b/demo/index.html @@ -61,6 +61,10 @@ @@include('./partials/accordion.html') @@include('./partials/stepper.html') @@include('./partials/file-upload.html') + @@include('./partials/popover.html') + @@include('./partials/pagination.html') + @@include('./partials/timeline.html') + @@include('./partials/loader.html') diff --git a/demo/partials/loader.html b/demo/partials/loader.html new file mode 100644 index 0000000..6072611 --- /dev/null +++ b/demo/partials/loader.html @@ -0,0 +1,20 @@ +
+

Loader

+ +

Spin:

+ + + + +

Dots:

+ + +

Bar:

+ + +

Block:

+
+ + Saving changes... +
+
diff --git a/demo/partials/pagination.html b/demo/partials/pagination.html new file mode 100644 index 0000000..1dea582 --- /dev/null +++ b/demo/partials/pagination.html @@ -0,0 +1,13 @@ +
+

Pagination

+ + +
diff --git a/demo/partials/popover.html b/demo/partials/popover.html new file mode 100644 index 0000000..29e05ab --- /dev/null +++ b/demo/partials/popover.html @@ -0,0 +1,11 @@ +
+

Popover

+ +
+ +
+

Popover title

+

This is a lightweight floating panel triggered by a button click.

+
+
+
diff --git a/demo/partials/timeline.html b/demo/partials/timeline.html new file mode 100644 index 0000000..fcf5dc6 --- /dev/null +++ b/demo/partials/timeline.html @@ -0,0 +1,42 @@ +
+

Timeline

+ +
    +
  1. + +
    +
    +
    +

    Order placed

    + +
    +

    Your order was received and is being prepared.

    +
    +
    +
  2. +
  3. + +
    +
    +
    +

    Shipped

    + +
    +

    Package left the fulfillment center.

    +
    Tracking: VMK-28491
    +
    +
    +
  4. +
  5. + +
    +
    +
    +

    Delivered

    +
    +

    Expected by tomorrow.

    +
    +
    +
  6. +
+
diff --git a/src/js/components/loader.js b/src/js/components/loader.js new file mode 100644 index 0000000..8f64677 --- /dev/null +++ b/src/js/components/loader.js @@ -0,0 +1,24 @@ +function html({ type = "spin", size = "md", label = "" } = {}) { + const labelText = label ? ` aria-label="${label}"` : " aria-hidden=\"true\""; + + if(type === "dots") { + return ``; + } + + if(type === "bar") { + return ``; + } + + return ``; +} + +function create(options = {}) { + const wrapper = document.createElement("div"); + wrapper.innerHTML = html(options); + return wrapper.firstElementChild; +} + +export default { + html, + create +}; diff --git a/src/js/components/popover.js b/src/js/components/popover.js new file mode 100644 index 0000000..b33d3d2 --- /dev/null +++ b/src/js/components/popover.js @@ -0,0 +1,66 @@ +const initializedRoots = new WeakSet(); + +function open(root) { + root.classList.add("is-open"); +} + +function close(root) { + root.classList.remove("is-open"); +} + +function isOpen(root) { + return root.classList.contains("is-open"); +} + +function toggle(root) { + if(isOpen(root)) { + close(root); + } else { + open(root); + } +} + +function init(root = document) { + if(initializedRoots.has(root)) { + return; + } + + root.addEventListener("click", event => { + const trigger = event.target.closest("[data-popover-toggle]"); + + if(trigger) { + const popover = trigger.closest(".popover"); + + if(popover) { + event.preventDefault(); + toggle(popover); + } + + return; + } + + const inside = event.target.closest(".popover-panel"); + + if(inside) { + return; + } + + root.querySelectorAll(".popover.is-open").forEach(popover => close(popover)); + }); + + root.addEventListener("keydown", event => { + if(event.key === "Escape") { + root.querySelectorAll(".popover.is-open").forEach(popover => close(popover)); + } + }); + + initializedRoots.add(root); +} + +export default { + init, + open, + close, + toggle, + isOpen +}; diff --git a/src/js/index.js b/src/js/index.js index e2f8a2e..bff3dc0 100644 --- a/src/js/index.js +++ b/src/js/index.js @@ -16,6 +16,8 @@ import Background from "./components/backgrounds.js"; import Breadcrumbs from "./components/breadcrumbs.js"; import Accordion from "./components/accordion.js"; +import Popover from "./components/popover.js"; +import Loader from "./components/loader.js"; const api = { Icons, @@ -31,6 +33,8 @@ Background, Breadcrumbs, Accordion, + Popover, + Loader, Helper: { iconSvg: Icons.iconSvg, iconNode: Icons.iconNode, @@ -42,7 +46,13 @@ accordionInit: Accordion.init, accordionExpand: Accordion.expand, accordionCollapse: Accordion.collapse, - accordionToggle: Accordion.toggle + accordionToggle: Accordion.toggle, + popoverInit: Popover.init, + popoverOpen: Popover.open, + popoverClose: Popover.close, + popoverToggle: Popover.toggle, + loaderHtml: Loader.html, + loaderCreate: Loader.create } }; @@ -54,5 +64,5 @@ }); } -export { Icons, Toasts, Tabs, Progress, Tables, Lists, Skeleton, Tooltips, Dropdowns, NavigationShell, Background, Breadcrumbs, Accordion }; +export { Icons, Toasts, Tabs, Progress, Tables, Lists, Skeleton, Tooltips, Dropdowns, NavigationShell, Background, Breadcrumbs, Accordion, Popover, Loader }; export default api; diff --git a/src/scss/components/_loader.scss b/src/scss/components/_loader.scss new file mode 100644 index 0000000..9d6bf00 --- /dev/null +++ b/src/scss/components/_loader.scss @@ -0,0 +1,122 @@ +/* ========================= + Loader / Spinner + Figma: Vertical Indicators. +========================= */ + +@use "../kit-deps" as *; + +@keyframes loader-spin { + to { + transform: rotate(360deg); + } +} + +@keyframes loader-pulse { + 0%, 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.5; + transform: scale(0.8); + } +} + +.loader { + display: inline-flex; + align-items: center; + justify-content: center; + color: $neutral-900; + + &.loader-sm { + font-size: 16px; + } + + &.loader-md { + font-size: 24px; + } + + &.loader-lg { + font-size: 40px; + } +} + +.loader-spin { + width: 1em; + height: 1em; + border: 0.15em solid currentColor; + border-right-color: transparent; + border-radius: $radius-full; + animation: loader-spin 0.8s linear infinite; +} + +.loader-dots { + display: inline-flex; + align-items: center; + gap: 0.3em; + + span { + width: 0.25em; + height: 0.25em; + border-radius: $radius-full; + background: currentColor; + animation: loader-pulse 1.2s ease-in-out infinite; + + &:nth-child(2) { + animation-delay: 0.2s; + } + + &:nth-child(3) { + animation-delay: 0.4s; + } + } +} + +.loader-bar { + width: 1em; + min-width: 40px; + height: 0.18em; + min-height: 4px; + border-radius: $radius-full; + background: $neutral-200; + overflow: hidden; + + &::after { + content: ""; + display: block; + width: 40%; + height: 100%; + border-radius: $radius-full; + background: currentColor; + animation: loader-bar 1.2s ease-in-out infinite; + } +} + +@keyframes loader-bar { + 0% { + transform: translateX(-120%); + } + 100% { + transform: translateX(250%); + } +} + +.loader-overlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba($neutral-0, 0.72); + z-index: 10; +} + +.loader-block { + display: flex; + flex-direction: column; + align-items: center; + gap: $space-3; + color: $neutral-700; + font-family: $font-family-base; + font-size: 14px; +} diff --git a/src/scss/components/_pagination.scss b/src/scss/components/_pagination.scss new file mode 100644 index 0000000..e0caf16 --- /dev/null +++ b/src/scss/components/_pagination.scss @@ -0,0 +1,62 @@ +/* ========================= + Pagination + API kept compatible with gnexus-ui-kit. +========================= */ + +@use "../kit-deps" as *; + +.pagination { + display: inline-flex; + align-items: center; + gap: $space-1; + margin: 0; + padding: $space-1; + border: $border-width-base $border-style-base $border-color-muted; + border-radius: $radius-lg; + background: $neutral-0; + list-style: none; +} + +.pagination-item { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 36px; + height: 36px; + padding: 0 $space-2; + border: $border-width-base $border-style-base transparent; + border-radius: $radius-md; + background: transparent; + color: $neutral-700; + font-family: $font-family-base; + font-size: 14px; + font-weight: $font-weight-semibold; + line-height: 1; + cursor: pointer; + transition: color $motion-base $motion-ease, background-color $motion-base $motion-ease, border-color $motion-base $motion-ease; + + &:disabled { + opacity: 0.4; + cursor: not-allowed; + } + + &:not(:disabled):hover, + &:not(:disabled).is-hover { + color: $neutral-900; + background: $neutral-50; + } + + &.pagination-item-active { + border-color: $neutral-900; + color: $neutral-900; + background: $neutral-0; + } + + &:focus-visible { + @include focus_ring; + } + + .ph { + font-size: $icon-size-md; + } +} diff --git a/src/scss/components/_popover.scss b/src/scss/components/_popover.scss new file mode 100644 index 0000000..3105ffb --- /dev/null +++ b/src/scss/components/_popover.scss @@ -0,0 +1,88 @@ +/* ========================= + Popover + API kept compatible with gnexus-ui-kit. +========================= */ + +@use "../kit-deps" as *; + +.popover { + position: relative; + display: inline-block; + + &.is-open .popover-panel { + opacity: 1; + transform: translateY(0); + pointer-events: auto; + visibility: visible; + } + + &.is-open .popover-trigger { + color: $neutral-900; + } +} + +.popover-panel { + position: absolute; + z-index: 100; + top: calc(100% + #{$space-2}); + left: 0; + width: max-content; + max-width: 320px; + padding: $space-4; + border: $border-width-base $border-style-base $border-color-muted; + border-radius: $radius-lg; + background: $neutral-0; + box-shadow: 0 8px 24px rgba($neutral-900, 0.08); + color: $neutral-700; + font-family: $font-family-base; + font-size: 14px; + line-height: 1.5; + opacity: 0; + transform: translateY(-4px); + pointer-events: none; + visibility: hidden; + transition: opacity $motion-base $motion-ease, transform $motion-base $motion-ease, visibility $motion-base $motion-ease; + + &::before { + content: ""; + position: absolute; + top: -5px; + left: $space-4; + width: 8px; + height: 8px; + background: $neutral-0; + border-left: $border-width-base $border-style-base $border-color-muted; + border-top: $border-width-base $border-style-base $border-color-muted; + transform: rotate(45deg); + } +} + +.popover-title { + margin: 0 0 $space-2; + color: $neutral-900; + font-family: $font-family-base; + font-size: 16px; + font-weight: $font-weight-semibold; + line-height: 1.3; +} + +.popover-text { + margin: 0; + color: $neutral-600; + font-size: 14px; + line-height: 1.4; +} + +.popover[data-position="right"] .popover-panel { + top: 0; + left: calc(100% + #{$space-2}); + + &::before { + top: $space-4; + left: -5px; + border: none; + border-left: $border-width-base $border-style-base $border-color-muted; + border-bottom: $border-width-base $border-style-base $border-color-muted; + transform: rotate(45deg); + } +} diff --git a/src/scss/components/_timeline.scss b/src/scss/components/_timeline.scss new file mode 100644 index 0000000..a87571c --- /dev/null +++ b/src/scss/components/_timeline.scss @@ -0,0 +1,142 @@ +/* ========================= + Timeline + API kept compatible with gnexus-ui-kit. +========================= */ + +@use "../kit-deps" as *; + +.timeline { + display: grid; + gap: $space-4; + width: 100%; + max-width: 760px; + margin: 0; + padding: 0; + list-style: none; +} + +.timeline-item { + position: relative; + display: flex; + gap: $space-4; + padding-left: $space-2; + + &::before { + content: ""; + position: absolute; + top: 32px; + bottom: -$space-4; + left: 15px; + width: 2px; + background: $border-color-muted; + } + + &:last-child::before { + display: none; + } +} + +.timeline-marker { + position: relative; + z-index: 1; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 32px; + height: 32px; + border-radius: $radius-full; + background: $neutral-100; + color: $neutral-700; + font-size: $icon-size-sm; +} + +.timeline-item-primary .timeline-marker { + background: $neutral-900; + color: $neutral-0; +} + +.timeline-item-secondary .timeline-marker { + background: $mint-300; + color: $neutral-900; +} + +.timeline-item-accent .timeline-marker { + background: $yellow-300; + color: $neutral-900; +} + +.timeline-item-success .timeline-marker { + background: $success-bg; + color: $success-text; +} + +.timeline-item-warning .timeline-marker { + background: $warning-bg; + color: $warning-text; +} + +.timeline-item-danger .timeline-marker, +.timeline-item-error .timeline-marker { + background: $danger-bg; + color: $danger-text; +} + +.timeline-item-info .timeline-marker { + background: $info-bg; + color: $info-text; +} + +.timeline-content { + flex: 1; + min-width: 0; +} + +.timeline-card { + padding: $space-4; + border: $border-width-base $border-style-base $border-color-muted; + border-radius: $radius-lg; + background: $surface-panel-muted; +} + +.timeline-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: $space-3; + margin-bottom: $space-2; +} + +.timeline-title { + margin: 0; + color: $neutral-900; + font-family: $font-family-base; + font-size: 16px; + font-weight: $font-weight-semibold; + line-height: 1.3; +} + +.timeline-time { + flex-shrink: 0; + color: $neutral-500; + font-family: $font-family-base; + font-size: 12px; + line-height: 1.3; +} + +.timeline-text { + margin: 0; + color: $neutral-700; + font-family: $font-family-base; + font-size: 14px; + line-height: 1.5; +} + +.timeline-meta { + display: flex; + align-items: center; + gap: $space-2; + margin-top: $space-3; + color: $neutral-600; + font-size: 13px; +} diff --git a/src/scss/kit.scss b/src/scss/kit.scss index a8495ab..47c8895 100644 --- a/src/scss/kit.scss +++ b/src/scss/kit.scss @@ -25,6 +25,10 @@ @use "components/accordion"; @use "components/stepper"; @use "components/file-upload"; +@use "components/popover"; +@use "components/pagination"; +@use "components/timeline"; +@use "components/loader"; @use "components/lists"; @use "components/skeleton"; @use "components/tooltip"; diff --git a/src/vue/components/GnLoader.js b/src/vue/components/GnLoader.js new file mode 100644 index 0000000..e0fc1b3 --- /dev/null +++ b/src/vue/components/GnLoader.js @@ -0,0 +1,41 @@ +/** + * GnLoader - Loading state indicator. + */ + +import { defineComponent, h } from "vue"; +import { cx } from "../utils.js"; + +export default defineComponent({ + name: "GnLoader", + props: { + type: { type: String, default: "spin" }, + size: { type: String, default: "md" }, + label: { type: String, default: "Loading" }, + block: { type: Boolean, default: false } + }, + setup(props) { + const sizeClass = ["sm", "md", "lg"].includes(props.size) ? `loader-${props.size}` : "loader-md"; + const typeClass = ["spin", "dots", "bar"].includes(props.type) ? `loader-${props.type}` : "loader-spin"; + + return () => { + const indicator = h("span", { + class: cx("loader", sizeClass, typeClass), + role: props.type === "bar" ? "progressbar" : "status", + "aria-label": props.label + }, [ + props.type === "spin" && h("span", { class: "loader-spin" }), + props.type === "dots" && [1, 2, 3].map(() => h("span")), + props.type === "bar" && h("span") + ]); + + if(props.block) { + return h("div", { class: "loader-block" }, [ + indicator, + h("span", props.label) + ]); + } + + return indicator; + }; + } +}); diff --git a/src/vue/components/GnPagination.js b/src/vue/components/GnPagination.js new file mode 100644 index 0000000..e1db55e --- /dev/null +++ b/src/vue/components/GnPagination.js @@ -0,0 +1,49 @@ +/** + * GnPagination - Page number navigation. + * Keeps API compatible with gnexus-ui-kit. + */ + +import { defineComponent, h } from "vue"; +import { cx, iconNode } from "../utils.js"; + +export default defineComponent({ + name: "GnPagination", + props: { + page: { type: Number, required: true }, + totalPages: { type: Number, required: true }, + ariaLabel: { type: String, default: "Pagination" } + }, + emits: ["update:page"], + setup(props, { emit }) { + const setPage = page => { + if(page >= 1 && page <= props.totalPages && page !== props.page) { + emit("update:page", page); + } + }; + + return () => { + const pages = Array.from({ length: props.totalPages }, (_, index) => index + 1); + + return h("nav", { class: "pagination", "aria-label": props.ariaLabel }, [ + h("button", { + class: "pagination-item", + type: "button", + disabled: props.page <= 1, + onClick: () => setPage(props.page - 1) + }, [iconNode("ph-arrow-left")]), + pages.map(page => h("button", { + class: cx("pagination-item", { "pagination-item-active": page === props.page }), + type: "button", + "aria-current": page === props.page ? "page" : undefined, + onClick: () => setPage(page) + }, String(page))), + h("button", { + class: "pagination-item", + type: "button", + disabled: props.page >= props.totalPages, + onClick: () => setPage(props.page + 1) + }, [iconNode("ph-arrow-right")]) + ]); + }; + } +}); diff --git a/src/vue/components/GnPopover.js b/src/vue/components/GnPopover.js new file mode 100644 index 0000000..dc8f1e2 --- /dev/null +++ b/src/vue/components/GnPopover.js @@ -0,0 +1,66 @@ +/** + * GnPopover - Click-triggered floating panel. + * Keeps API compatible with gnexus-ui-kit. + */ + +import { defineComponent, h, onBeforeUnmount, ref } from "vue"; +import { cx } from "../utils.js"; +import GnButton from "./GnButton.js"; + +export default defineComponent({ + name: "GnPopover", + props: { + label: { type: String, default: "Details" }, + title: { type: String, default: "" }, + text: { type: String, default: "" }, + icon: { type: String, default: "ph-info" }, + variant: { type: String, default: "accent" }, + position: { type: String, default: "bottom" } + }, + setup(props, { slots }) { + const open = ref(false); + const root = ref(null); + const close = () => { + open.value = false; + document.removeEventListener("click", onOutsideClick); + document.removeEventListener("keydown", onKeydown); + }; + const onOutsideClick = event => { + if(root.value && !root.value.contains(event.target)) { + close(); + } + }; + const onKeydown = event => { + if(event.key === "Escape") { + event.preventDefault(); + close(); + } + }; + const toggle = () => { + open.value = !open.value; + + if(open.value) { + setTimeout(() => document.addEventListener("click", onOutsideClick), 0); + document.addEventListener("keydown", onKeydown); + } else { + close(); + } + }; + + onBeforeUnmount(close); + + return () => h("div", { ref: root, class: cx("popover", { "is-open": open.value }), "data-position": props.position }, [ + slots.trigger?.({ open: open.value, toggle }) || h(GnButton, { + class: "popover-trigger", + variant: props.variant, + icon: props.icon, + "aria-expanded": open.value ? "true" : "false", + onClick: toggle + }, () => props.label), + h("div", { class: "popover-panel" }, [ + (props.title || slots.title) && h("h3", { class: "popover-title" }, slots.title?.() || props.title), + (props.text || slots.default) && h("p", { class: "popover-text" }, slots.default?.() || props.text) + ]) + ]); + } +}); diff --git a/src/vue/components/GnTimeline.js b/src/vue/components/GnTimeline.js new file mode 100644 index 0000000..9e38137 --- /dev/null +++ b/src/vue/components/GnTimeline.js @@ -0,0 +1,34 @@ +/** + * GnTimeline - Vertical activity timeline. + * Keeps API compatible with gnexus-ui-kit. + */ + +import { defineComponent, h } from "vue"; +import { cx, iconNode, normalizeVariant } from "../utils.js"; + +export default defineComponent({ + name: "GnTimeline", + inheritAttrs: false, + props: { + items: { type: Array, default: () => [] } + }, + setup(props, { attrs, slots }) { + return () => h("ol", { ...attrs, class: cx("timeline", attrs.class) }, props.items.map(item => { + const variant = item.variant ? normalizeVariant(item.variant) : ""; + + return h("li", { class: cx("timeline-item", variant && `timeline-item-${variant}`) }, [ + h("span", { class: "timeline-marker" }, [iconNode(item.icon || "ph-circle")]), + h("div", { class: "timeline-content" }, [ + h("article", { class: "timeline-card" }, [ + h("header", { class: "timeline-header" }, [ + h("h3", { class: "timeline-title" }, item.title), + item.time && h("time", { class: "timeline-time" }, item.time) + ]), + h("p", { class: "timeline-text" }, slots[item.key]?.({ item }) || item.text), + (item.meta || slots.meta) && h("div", { class: "timeline-meta" }, slots.meta?.({ item }) || item.meta) + ]) + ]) + ]); + })); + } +}); diff --git a/src/vue/index.js b/src/vue/index.js index af15014..c36ddf5 100644 --- a/src/vue/index.js +++ b/src/vue/index.js @@ -42,5 +42,9 @@ export { default as GnSteps } from "./components/GnSteps.js"; export { default as GnFileUpload } from "./components/GnFileUpload.js"; export { default as GnFileAttachment } from "./components/GnFileAttachment.js"; +export { default as GnPopover } from "./components/GnPopover.js"; +export { default as GnPagination } from "./components/GnPagination.js"; +export { default as GnTimeline } from "./components/GnTimeline.js"; +export { default as GnLoader } from "./components/GnLoader.js"; export { useToast } from "./composables/useToast.js"; export { components, default as VMKUiVue } from "./plugin.js"; diff --git a/src/vue/plugin.js b/src/vue/plugin.js index 66c0372..a852aea 100644 --- a/src/vue/plugin.js +++ b/src/vue/plugin.js @@ -42,6 +42,10 @@ import GnSteps from "./components/GnSteps.js"; import GnFileUpload from "./components/GnFileUpload.js"; import GnFileAttachment from "./components/GnFileAttachment.js"; +import GnPopover from "./components/GnPopover.js"; +import GnPagination from "./components/GnPagination.js"; +import GnTimeline from "./components/GnTimeline.js"; +import GnLoader from "./components/GnLoader.js"; export const components = { GnButton, @@ -82,7 +86,11 @@ GnAccordion, GnSteps, GnFileUpload, - GnFileAttachment + GnFileAttachment, + GnPopover, + GnPagination, + GnTimeline, + GnLoader }; export default {