diff --git a/demo/index.html b/demo/index.html index c5769f9..c270555 100644 --- a/demo/index.html +++ b/demo/index.html @@ -74,6 +74,7 @@ @@include('./partials/pagination.html') @@include('./partials/timeline.html') @@include('./partials/loader.html') + @@include('./partials/option-card-chat.html') diff --git a/demo/partials/option-card-chat.html b/demo/partials/option-card-chat.html new file mode 100644 index 0000000..8873898 --- /dev/null +++ b/demo/partials/option-card-chat.html @@ -0,0 +1,224 @@ +
+

Option Card & Chat

+ +

Option card vertical

+
+
+ + 95% +
+
+
+

+ 2 500 000 UAH + 52,40 м² +

+
+

Compact apartment near city center

+
+ Verified + Owner +
+
+ + +
+
+
+ +

Option card horizontal

+
+
+ +
+
+
+

+ 3 200 000 UAH + 78,00 м² +

+ +
+

Spacious flat with balcony and parking

+
+ Unverified + Agent +
+
+
+ +

Vue option card

+
+ + +

Chat messages

+
+
+

Show me apartments under 2 million UAH.

+
+ + +
+
+ +
+

Found 12 options

+
+

I found 12 apartments matching your budget. Most are 1-bedroom studios in the city center.

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

Vue chat messages

+
+ + +

Data rows

+
+
+ +
+
+
+ File + summer-header-1.png +
+
+ Owner + Fikri Maulana +
+
+ Date + Mon, Jul 15, 2025 +
+
+ Size + 1.234 KB +
+
+
+ +
+
+
+ +
+
+
+ File + summer-header-1.png +
+
+ Owner + Fikri Maulana +
+
+ Date + Mon, Jul 15, 2025 +
+
+ Size + 1.234 KB +
+
+
+
+ +

Vue data row

+
+ +
\ No newline at end of file diff --git a/docs/component-coverage.md b/docs/component-coverage.md index 90055b6..0343b7f 100644 --- a/docs/component-coverage.md +++ b/docs/component-coverage.md @@ -51,5 +51,8 @@ | FileUpload | done | GnFileUpload, GnFileAttachment | partial | | Repeater | done | GnRepeater | partial | | UsageMeter | done | GnUsageMeter | partial | +| OptionCard | done | GnOptionCard | partial | +| ChatMessage | done | GnChatMessage | partial | +| DataRow | done | GnDataRow | partial | Статусы: `—` не начато, `WIP` в работе, `done` готово, `redesigned` перерисован в новом стиле. diff --git a/src/scss/components/_chat.scss b/src/scss/components/_chat.scss new file mode 100644 index 0000000..956c401 --- /dev/null +++ b/src/scss/components/_chat.scss @@ -0,0 +1,212 @@ +/* ========================= + Chat / Messenger + Figma: Chat Messages & Bubbles / Messenger Messages & Bubbles. +========================= */ + +@use "../kit-deps" as *; + +.chat-message { + display: flex; + flex-direction: column; + gap: $space-2; + max-width: 80%; + + &.chat-message-user { + align-self: flex-end; + align-items: flex-end; + + .chat-bubble { + background-color: $mint-100; + border-color: $mint-200; + color: $neutral-900; + border-bottom-right-radius: $radius-xs; + } + } + + &.chat-message-ai { + align-self: flex-start; + align-items: flex-start; + + .chat-bubble { + background-color: $neutral-0; + border-color: $neutral-200; + color: $neutral-900; + border-bottom-left-radius: $radius-xs; + } + } +} + +.chat-bubble { + padding: $space-3 $space-4; + border: 1px solid; + border-radius: $radius-lg; + font-size: $font-size-base; + line-height: 1.5; + word-break: break-word; + + p { + margin: 0; + + & + p { + margin-top: $space-2; + } + } + + ul, + ol { + margin: $space-2 0 0; + padding-left: $space-5; + } + + li + li { + margin-top: $space-1; + } +} + +.chat-message-title { + margin: 0; + color: $neutral-700; + font-size: $font-size-sm; + font-weight: $font-weight-semibold; +} + +.chat-message-actions { + display: inline-flex; + align-items: center; + gap: $space-2; + + .chat-message-action { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: 0; + border-radius: $radius-md; + background: transparent; + color: $neutral-500; + cursor: pointer; + transition: + background-color $motion-base $motion-ease, + color $motion-base $motion-ease; + + &:hover { + background-color: rgba($neutral-900, 0.06); + color: $neutral-900; + } + + &:focus-visible { + @include focus_ring; + } + } +} + +.chat-message-options { + display: flex; + flex-direction: column; + gap: $space-2; + width: 100%; + margin-top: $space-2; + + .chat-option { + display: flex; + align-items: center; + justify-content: center; + gap: $space-2; + width: 100%; + padding: $space-3 $space-4; + border: 1px solid $neutral-200; + border-radius: $radius-lg; + background-color: $neutral-0; + color: $neutral-900; + font: inherit; + font-size: $font-size-base; + font-weight: $font-weight-semibold; + cursor: pointer; + transition: + background-color $motion-base $motion-ease, + border-color $motion-base $motion-ease; + + &:hover { + background-color: $yellow-100; + border-color: $yellow-400; + } + + &:focus-visible { + @include focus_ring; + } + } +} + +.chat-input-container { + display: flex; + align-items: center; + gap: $space-3; + width: 100%; + padding: $space-2 $space-3 $space-2 $space-4; + border: 1px solid $neutral-200; + border-radius: $radius-full; + background-color: $neutral-0; + + .chat-input { + flex: 1 1 auto; + min-width: 0; + padding: $space-2 0; + border: 0; + background: transparent; + color: $neutral-900; + font: inherit; + font-size: $font-size-base; + line-height: 1.4; + + &::placeholder { + color: $neutral-500; + } + + &:focus { + outline: none; + } + } + + .chat-input-actions { + display: inline-flex; + align-items: center; + gap: $space-1; + flex-shrink: 0; + } +} + +.chat-typing { + display: inline-flex; + align-items: center; + gap: $space-1; + padding: $space-3 $space-4; + border-radius: $radius-lg; + background-color: $neutral-0; + border: 1px solid $neutral-200; + + span { + width: 6px; + height: 6px; + border-radius: $radius-full; + background-color: $neutral-400; + animation: chat-typing-bounce 1.4s infinite ease-in-out both; + + &:nth-child(1) { animation-delay: -0.32s; } + &:nth-child(2) { animation-delay: -0.16s; } + } +} + +@keyframes chat-typing-bounce { + 0%, 80%, 100% { transform: scale(0.6); opacity: 0.6; } + 40% { transform: scale(1); opacity: 1; } +} + +.chat-list { + display: flex; + flex-direction: column; + gap: $space-4; + width: 100%; + max-width: 720px; +} diff --git a/src/scss/components/_data-row.scss b/src/scss/components/_data-row.scss new file mode 100644 index 0000000..b365d3c --- /dev/null +++ b/src/scss/components/_data-row.scss @@ -0,0 +1,132 @@ +/* ========================= + Data Row + Figma: Row - Asset List / Order list / Audience contact list. + A compact table-like row with leading checkbox, media, columns and actions. +========================= */ + +@use "../kit-deps" as *; + +.data-row { + display: flex; + align-items: center; + gap: $space-3; + width: 100%; + padding: $space-3 $space-4; + border-bottom: 1px solid $neutral-100; + background-color: $neutral-0; + transition: background-color $motion-base $motion-ease; + + &:last-child { + border-bottom: 0; + } + + @include hover_touch { + background-color: $neutral-50; + } + + &.data-row-selected { + background-color: rgba($mint-500, 0.08); + } +} + +.data-row-select { + display: inline-flex; + align-items: center; + flex-shrink: 0; +} + +.data-row-media { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 44px; + height: 44px; + border-radius: $radius-md; + background-color: $neutral-100; + color: $neutral-500; + font-size: $icon-size-md; + overflow: hidden; + + img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + } +} + +.data-row-content { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: $space-3 $space-4; + flex: 1 1 auto; + min-width: 0; +} + +.data-row-cell { + display: flex; + flex-direction: column; + gap: $space-1; + min-width: 0; + + &.data-row-cell-main { + .data-row-value { + font-weight: $font-weight-semibold; + color: $neutral-900; + } + } +} + +.data-row-label { + color: $neutral-500; + font-size: $font-size-xs; + font-weight: $font-weight-semibold; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.data-row-value { + color: $neutral-700; + font-size: $font-size-sm; + font-weight: $font-weight-medium; + line-height: 1.4; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.data-row-actions { + display: inline-flex; + align-items: center; + gap: $space-1; + flex-shrink: 0; + margin-left: auto; +} + +.data-row-group { + display: flex; + flex-direction: column; + width: 100%; + border: 1px solid $neutral-200; + border-radius: $radius-lg; + overflow: hidden; +} + +@media (max-width: 640px) { + .data-row { + align-items: flex-start; + flex-wrap: wrap; + } + + .data-row-content { + grid-template-columns: 1fr 1fr; + width: 100%; + } + + .data-row-actions { + width: 100%; + justify-content: flex-end; + margin-left: 0; + } +} diff --git a/src/scss/components/_option-card.scss b/src/scss/components/_option-card.scss new file mode 100644 index 0000000..42483d9 --- /dev/null +++ b/src/scss/components/_option-card.scss @@ -0,0 +1,251 @@ +/* ========================= + Option Card + Figma: Option Card / Campaign search card / Contact search card. + A search-result-style card with media, price, area, badges and actions. +========================= */ + +@use "../kit-deps" as *; + +.option-card { + display: flex; + flex-direction: column; + width: 100%; + max-width: 420px; + background-color: $neutral-0; + border: 1px solid $neutral-200; + border-radius: $radius-xl; + overflow: hidden; + transition: + box-shadow $motion-base $motion-ease, + border-color $motion-base $motion-ease; + + @include hover_touch { + border-color: $neutral-300; + box-shadow: 0 4px 24px rgba($neutral-900, 0.08); + } + + &.option-card-horizontal { + flex-direction: row; + max-width: 720px; + + .option-card-media { + flex: 0 0 38%; + min-width: 140px; + max-width: 280px; + aspect-ratio: auto; + height: auto; + } + + .option-card-body { + flex: 1 1 auto; + } + } + + &.option-card-expanded { + .option-card-stats { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: $space-4; + padding: $space-3 $space-4; + border-top: 1px solid $neutral-100; + + .option-card-stat { + display: flex; + align-items: center; + gap: $space-2; + color: $neutral-600; + font-size: $font-size-sm; + font-weight: $font-weight-semibold; + + .ph { + color: $neutral-400; + font-size: $icon-size-md; + } + + strong { + color: $neutral-900; + font-weight: $font-weight-bold; + } + } + } + } +} + +.option-card-media { + position: relative; + width: 100%; + aspect-ratio: 16 / 10; + background-color: $neutral-100; + overflow: hidden; + + img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + } + + .option-card-media-placeholder { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + color: $neutral-400; + font-size: $icon-size-xl; + } + + .option-card-match { + position: absolute; + top: $space-3; + left: $space-3; + display: inline-flex; + align-items: center; + gap: $space-2; + padding: $space-1 $space-2; + border-radius: $radius-md; + background-color: $neutral-0; + color: $neutral-700; + font-size: $font-size-xs; + font-weight: $font-weight-semibold; + box-shadow: 0 2px 8px rgba($neutral-900, 0.06); + + .ph { + color: $success-text; + font-size: $icon-size-sm; + } + } + + .option-card-media-count { + position: absolute; + bottom: $space-3; + right: $space-3; + padding: $space-1 $space-2; + border-radius: $radius-md; + background-color: rgba($neutral-900, 0.6); + color: $neutral-0; + font-size: $font-size-xs; + font-weight: $font-weight-semibold; + } + + .option-card-dots { + position: absolute; + bottom: $space-3; + left: 50%; + transform: translateX(-50%); + display: flex; + gap: $space-1; + + span { + width: 6px; + height: 6px; + border-radius: $radius-full; + background-color: rgba($neutral-0, 0.5); + + &.is-active { + background-color: $neutral-0; + } + } + } +} + +.option-card-body { + display: flex; + flex-direction: column; + gap: $space-3; + padding: $space-4; +} + +.option-card-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: $space-3; +} + +.option-card-price { + display: flex; + align-items: baseline; + gap: $space-2; + margin: 0; + color: $neutral-900; + font-size: $font-size-h4; + font-weight: $font-weight-bold; + line-height: 1.2; + + .option-card-area { + color: $neutral-500; + font-size: $font-size-sm; + font-weight: $font-weight-semibold; + } +} + +.option-card-title { + margin: 0; + color: $neutral-500; + font-size: $font-size-sm; + font-weight: $font-weight-medium; + line-height: 1.4; +} + +.option-card-badges { + display: flex; + flex-wrap: wrap; + gap: $space-2; +} + +.option-card-actions { + display: flex; + align-items: center; + gap: $space-3; + padding-top: $space-2; +} + +.option-card-details { + padding: $space-3 $space-4; + border-top: 1px solid $neutral-100; + background-color: $neutral-50; +} + +.option-card-detail-list { + display: grid; + grid-template-columns: 1fr; + gap: $space-3; + margin: 0; + padding: 0; + list-style: none; + + .option-card-detail-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: $space-3; + color: $neutral-700; + font-size: $font-size-sm; + + .ph { + color: $neutral-500; + margin-right: $space-2; + } + + strong { + color: $neutral-900; + font-weight: $font-weight-semibold; + } + } +} + +@media (max-width: 560px) { + .option-card.option-card-horizontal { + flex-direction: column; + + .option-card-media { + max-width: none; + aspect-ratio: 16 / 10; + } + } + + .option-card.option-card-expanded .option-card-stats { + grid-template-columns: 1fr; + } +} diff --git a/src/scss/kit.scss b/src/scss/kit.scss index 82ca5aa..da4e628 100644 --- a/src/scss/kit.scss +++ b/src/scss/kit.scss @@ -43,6 +43,9 @@ @use "components/dropdown"; @use "components/advanced-select"; @use "components/navigation-shell"; +@use "components/option-card"; +@use "components/chat"; +@use "components/data-row"; @use "utils"; * { diff --git a/src/vue/components/GnChatMessage.js b/src/vue/components/GnChatMessage.js new file mode 100644 index 0000000..ff3e9f2 --- /dev/null +++ b/src/vue/components/GnChatMessage.js @@ -0,0 +1,45 @@ +/** + * GnChatMessage — single chat message bubble (user or AI). + * + * Figma: Chat Messages & Bubbles / Messenger Messages & Bubbles. + */ + +import { defineComponent, h } from "vue"; +import { cx, iconNode } from "../utils.js"; + +export default defineComponent({ + name: "GnChatMessage", + inheritAttrs: false, + props: { + sender: { type: String, default: "user" }, // user | ai + title: { type: String, default: "" }, + actions: { type: Array, default: () => [] }, + options: { type: Array, default: () => [] } + }, + emits: ["action", "option"], + setup(props, { attrs, slots, emit }) { + return () => h("div", { + ...attrs, + class: cx("chat-message", `chat-message-${props.sender}`, attrs.class) + }, [ + props.title && h("h4", { class: "chat-message-title" }, props.title), + h("div", { class: "chat-bubble" }, slots.default?.()), + (props.actions.length || slots.actions) && h("div", { class: "chat-message-actions" }, [ + slots.actions?.(), + props.actions.map(action => h("button", { + class: "chat-message-action", + type: "button", + "aria-label": action.label, + onClick: () => emit("action", action.key || action.label) + }, iconNode(action.icon))) + ]), + props.options.length > 0 && h("div", { class: "chat-message-options" }, + props.options.map(opt => h("button", { + class: "chat-option", + type: "button", + onClick: () => emit("option", opt) + }, opt.icon && iconNode(opt.icon), opt.label)) + ) + ]); + } +}); diff --git a/src/vue/components/GnDataRow.js b/src/vue/components/GnDataRow.js new file mode 100644 index 0000000..3e87d1a --- /dev/null +++ b/src/vue/components/GnDataRow.js @@ -0,0 +1,57 @@ +/** + * GnDataRow — compact data table/list row. + * + * Figma: Row - Asset List / Order list / Audience contact list. + */ + +import { defineComponent, h } from "vue"; +import { cx, iconNode } from "../utils.js"; + +export default defineComponent({ + name: "GnDataRow", + inheritAttrs: false, + props: { + selected: { type: Boolean, default: false }, + image: { type: String, default: "" }, + icon: { type: String, default: "" }, + columns: { type: Array, default: () => [] }, + actions: { type: Array, default: () => [] } + }, + emits: ["select", "action"], + setup(props, { attrs, slots, emit }) { + return () => h("div", { + ...attrs, + class: cx("data-row", { "data-row-selected": props.selected }, attrs.class) + }, [ + slots.select?.() || h("label", { class: "data-row-select" }, [ + h("input", { + type: "checkbox", + checked: props.selected, + onChange: event => emit("select", event.target.checked) + }) + ]), + (props.image || props.icon || slots.media) && h("div", { class: "data-row-media" }, [ + slots.media?.(), + props.image && !slots.media && h("img", { src: props.image, alt: "" }), + props.icon && !props.image && !slots.media && iconNode(props.icon) + ]), + h("div", { class: "data-row-content" }, + props.columns.map((col, index) => h("div", { + class: cx("data-row-cell", { "data-row-cell-main": col.main }) + }, [ + col.label && h("span", { class: "data-row-label" }, col.label), + h("span", { class: "data-row-value" }, slots[`col-${index}`]?.({ col }) || col.value) + ])) + ), + (props.actions.length || slots.actions) && h("div", { class: "data-row-actions" }, [ + slots.actions?.(), + props.actions.map(action => h("button", { + class: cx("btn", "btn-icon", `btn-${action.variant || "secondary"}`, action.size ? `btn-${action.size}` : "btn-sm"), + type: "button", + "aria-label": action.label, + onClick: () => emit("action", action.key || action.label) + }, iconNode(action.icon))) + ]) + ]); + } +}); diff --git a/src/vue/components/GnOptionCard.js b/src/vue/components/GnOptionCard.js new file mode 100644 index 0000000..81dc506 --- /dev/null +++ b/src/vue/components/GnOptionCard.js @@ -0,0 +1,103 @@ +/** + * GnOptionCard — search-result / listing card. + * + * Figma: Option Card, Campaign search card, Contact search card. + */ + +import { defineComponent, h } from "vue"; +import { cx, iconNode } from "../utils.js"; +import GnChip from "./GnChip.js"; + +export default defineComponent({ + name: "GnOptionCard", + inheritAttrs: false, + props: { + layout: { type: String, default: "vertical" }, // vertical | horizontal + image: { type: String, default: "" }, + match: { type: [String, Number], default: "" }, + price: { type: String, default: "" }, + area: { type: String, default: "" }, + title: { type: String, default: "" }, + badges: { type: Array, default: () => [] }, + expanded: { type: Boolean, default: false }, + stats: { type: Array, default: () => [] }, + details: { type: Array, default: () => [] }, + actions: { type: Array, default: () => [] } + }, + emits: ["action", "select"], + setup(props, { attrs, slots, emit }) { + const mediaNode = () => h("div", { class: "option-card-media" }, [ + props.image + ? h("img", { src: props.image, alt: "" }) + : h("span", { class: "option-card-media-placeholder" }, iconNode("ph-image")), + props.match && h("span", { class: "option-card-match" }, [ + iconNode("ph-thumbs-up"), + `${props.match}%` + ]), + slots.mediaCount?.() || (props.image && h("span", { class: "option-card-media-count" }, "1/5")), + slots.dots?.() + ]); + + const headerNode = () => h("div", { class: "option-card-header" }, [ + h("p", { class: "option-card-price" }, [ + props.price, + props.area && h("span", { class: "option-card-area" }, props.area) + ]), + slots.actionsTop?.() + ]); + + const badgesNode = () => props.badges.length > 0 && h("div", { class: "option-card-badges" }, + props.badges.map(badge => h(GnChip, { + variant: badge.variant || "default", + removable: badge.removable, + onRemove: () => emit("action", { type: "remove-badge", badge }) + }, () => badge.label)) + .concat(slots.badges?.() || []) + ); + + const actionsNode = () => (props.actions.length || slots.actions) && h("div", { class: "option-card-actions" }, [ + slots.actions?.(), + props.actions.map(action => h("button", { + class: cx("btn", `btn-${action.variant || "secondary"}`, action.size ? `btn-${action.size}` : "btn-sm"), + type: "button", + onClick: () => emit("action", { type: action.key || action.label, action }) + }, action.icon && iconNode(action.icon), action.label)) + ]); + + const statsNode = () => props.expanded && props.stats.length > 0 && h("div", { class: "option-card-stats" }, + props.stats.map(stat => h("div", { class: "option-card-stat" }, [ + iconNode(stat.icon), + h("span", null, [stat.label, " ", h("strong", null, stat.value)]) + ])) + ); + + const detailsNode = () => props.expanded && props.details.length > 0 && h("div", { class: "option-card-details" }, [ + h("ul", { class: "option-card-detail-list" }, props.details.map(item => h("li", { class: "option-card-detail-item" }, [ + h("span", null, [item.icon && iconNode(item.icon), item.label]), + h("strong", null, item.value) + ]))) + ]); + + return () => h("article", { + ...attrs, + class: cx( + "option-card", + `option-card-${props.layout}`, + { "option-card-expanded": props.expanded }, + attrs.class + ), + onClick: () => emit("select") + }, [ + mediaNode(), + h("div", { class: "option-card-body" }, [ + headerNode(), + props.title && h("h3", { class: "option-card-title" }, props.title), + slots.default?.(), + badgesNode(), + actionsNode() + ]), + statsNode(), + detailsNode() + ]); + } +}); diff --git a/src/vue/index.js b/src/vue/index.js index c2ee38c..82a7269 100644 --- a/src/vue/index.js +++ b/src/vue/index.js @@ -67,6 +67,9 @@ export { default as GnTimeline } from "./components/GnTimeline.js"; export { default as GnLoader } from "./components/GnLoader.js"; export { default as GnHorizontalCard } from "./components/GnHorizontalCard.js"; +export { default as GnOptionCard } from "./components/GnOptionCard.js"; +export { default as GnDataRow } from "./components/GnDataRow.js"; +export { default as GnChatMessage } from "./components/GnChatMessage.js"; export { default as GnUserCard } from "./components/GnUserCard.js"; export { default as GnMetricCard } from "./components/GnMetricCard.js"; export { default as GnStatusCard } from "./components/GnStatusCard.js"; diff --git a/src/vue/plugin.js b/src/vue/plugin.js index e56082a..c787824 100644 --- a/src/vue/plugin.js +++ b/src/vue/plugin.js @@ -67,6 +67,9 @@ import GnTimeline from "./components/GnTimeline.js"; import GnLoader from "./components/GnLoader.js"; import GnHorizontalCard from "./components/GnHorizontalCard.js"; +import GnOptionCard from "./components/GnOptionCard.js"; +import GnDataRow from "./components/GnDataRow.js"; +import GnChatMessage from "./components/GnChatMessage.js"; import GnUserCard from "./components/GnUserCard.js"; import GnMetricCard from "./components/GnMetricCard.js"; import GnStatusCard from "./components/GnStatusCard.js"; @@ -143,6 +146,9 @@ GnTimeline, GnLoader, GnHorizontalCard, + GnOptionCard, + GnDataRow, + GnChatMessage, GnUserCard, GnMetricCard, GnStatusCard,