diff --git a/demo/partials/accordion.html b/demo/partials/accordion.html index 7e4d49c..0c8320c 100644 --- a/demo/partials/accordion.html +++ b/demo/partials/accordion.html @@ -54,4 +54,28 @@ + +

Vue accordion

+
+ diff --git a/demo/partials/backgrounds.html b/demo/partials/backgrounds.html index f38ffab..6360a0d 100644 --- a/demo/partials/backgrounds.html +++ b/demo/partials/backgrounds.html @@ -57,4 +57,27 @@ color: #1F1F1F; } + +

Vue backgrounds

+
+ diff --git a/demo/partials/breadcrumbs.html b/demo/partials/breadcrumbs.html index 15eb67e..b0febe4 100644 --- a/demo/partials/breadcrumbs.html +++ b/demo/partials/breadcrumbs.html @@ -82,4 +82,22 @@ + +

Vue breadcrumbs

+
+ diff --git a/demo/partials/cards.html b/demo/partials/cards.html index 154f7f6..3f8ec96 100644 --- a/demo/partials/cards.html +++ b/demo/partials/cards.html @@ -66,6 +66,150 @@ +

Status cards

+
+
+ Healthy +
+
+
+
+

All systems operational.

+
+
+ +
+ Pending +
+
+
+
+

Waiting for approval.

+
+
+ +
+ Failed +
+
+
+
+

Build failed at step 4.

+
+
+
+ +

Metric card

+
+
+
+
+

Monthly usage

+ +
+

84.2%

+
+ +12.8% + vs previous period +
+
+
+
+ +

Horizontal card

+
+
+
+ +
+
+

Project Alpha

+
+

Compact horizontal layout for content listings, search results, and entity rows.

+
+ +
+
+
+ +

Login card

+
+
+ +
+ +
+
+
+ +

User card — compact

+
+
+ +
+
+ +

User card — full

+
+
+
+ + + + + + Eugene Sukhodolskiy + goldmikki@gmail.com + + + Lead Frontend Engineer +
+ + +
+
+
+
+

Vue cards

+ +

Vue card gallery

+ + diff --git a/demo/partials/data-patterns.html b/demo/partials/data-patterns.html index 8d0b37a..30b5cf6 100644 --- a/demo/partials/data-patterns.html +++ b/demo/partials/data-patterns.html @@ -30,4 +30,91 @@ +

Activity log

+
+
+ + Build completed +
+
+ + Review requested +
+
+ + Commit pushed +
+
+ +

Toolbar

+
+
+
+

Resources

+ 12 items selected +
+
+
+ + +
+
+ +

Vue toolbar and activity log

+
+ + +

Vue repeater

+
+ diff --git a/demo/partials/drawer.html b/demo/partials/drawer.html index ecc92b1..c245b2e 100644 --- a/demo/partials/drawer.html +++ b/demo/partials/drawer.html @@ -21,4 +21,35 @@ drawer.show(); } + +

Vue drawer

+
+ diff --git a/demo/partials/dropdowns.html b/demo/partials/dropdowns.html index 25010c1..fe1a545 100644 --- a/demo/partials/dropdowns.html +++ b/demo/partials/dropdowns.html @@ -19,4 +19,30 @@ + +

Vue dropdown

+
+ diff --git a/demo/partials/file-upload.html b/demo/partials/file-upload.html index e88fa8a..e548dd4 100644 --- a/demo/partials/file-upload.html +++ b/demo/partials/file-upload.html @@ -73,4 +73,21 @@ + +

Vue file upload

+
+ diff --git a/demo/partials/modals.html b/demo/partials/modals.html index dbe7db7..f58371f 100644 --- a/demo/partials/modals.html +++ b/demo/partials/modals.html @@ -23,4 +23,30 @@ modal.show(); } + +

Vue modal

+
+ diff --git a/demo/partials/navigation-shell.html b/demo/partials/navigation-shell.html index 5e7d92b..6e9ffdc 100644 --- a/demo/partials/navigation-shell.html +++ b/demo/partials/navigation-shell.html @@ -1,4 +1,4 @@ -
+
+ +

Vue navigation shell

+
+
diff --git a/demo/partials/popover.html b/demo/partials/popover.html index 6ae34a5..d096f6e 100644 --- a/demo/partials/popover.html +++ b/demo/partials/popover.html @@ -8,4 +8,18 @@

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

+ +

Vue popover

+
+
diff --git a/demo/partials/progress.html b/demo/partials/progress.html index 600f665..45badda 100644 --- a/demo/partials/progress.html +++ b/demo/partials/progress.html @@ -33,4 +33,53 @@ 3 4 + +

Vue progress

+
+ + +

Usage meter

+
+

+ Storage + 78% +

+
+
+
+

312 GB used from 400 GB limit.

+
+ +

Vue usage meter

+
+ diff --git a/demo/partials/skeleton.html b/demo/partials/skeleton.html index 337bc3e..b3b3b8b 100644 --- a/demo/partials/skeleton.html +++ b/demo/partials/skeleton.html @@ -1,4 +1,4 @@ -
+

Skeleton

Text placeholders

@@ -15,4 +15,27 @@ + +

Vue skeleton

+
+
diff --git a/demo/partials/tabs.html b/demo/partials/tabs.html index 2a75637..ae7609f 100644 --- a/demo/partials/tabs.html +++ b/demo/partials/tabs.html @@ -1,4 +1,4 @@ -
+

Tabs

Pill tabs

@@ -60,4 +60,68 @@ + +

Vue tabs

+
+ + +

Vue router tabs (fallback without router)

+
+
diff --git a/demo/partials/timeline.html b/demo/partials/timeline.html index fcf5dc6..20bdda5 100644 --- a/demo/partials/timeline.html +++ b/demo/partials/timeline.html @@ -39,4 +39,27 @@ + +

Vue timeline

+
+
diff --git a/demo/partials/toasts.html b/demo/partials/toasts.html index 8ba09a9..ae8a0d1 100644 --- a/demo/partials/toasts.html +++ b/demo/partials/toasts.html @@ -15,4 +15,29 @@ Error toast + +

Vue toasts

+
+
diff --git a/demo/partials/tooltips.html b/demo/partials/tooltips.html index 8278fe1..882dacb 100644 --- a/demo/partials/tooltips.html +++ b/demo/partials/tooltips.html @@ -22,4 +22,43 @@ Tooltip on right + +

Vue tooltips

+
+ diff --git a/src/scss/_design-tokens.scss b/src/scss/_design-tokens.scss index 72f3486..0be48f3 100644 --- a/src/scss/_design-tokens.scss +++ b/src/scss/_design-tokens.scss @@ -28,6 +28,34 @@ $border-color-mint: $mint-600; // color.border.mint /* ========================= + TYPOGRAPHY +========================= */ + +$font-size-xs: 12px; +$font-size-sm: 14px; +$font-size-md: 15px; +$font-size-base: 16px; +$font-size-lg: 18px; +$font-size-xl: 22px; + +$font-size-h1: 40px; +$font-size-h2: 32px; +$font-size-h3: 28px; +$font-size-h4: 22px; +$font-size-h5: 18px; +$font-size-h6: 16px; + +$line-height-base: 1; +$line-height-tight: 1.15; +$line-height-snug: 1.25; +$line-height-normal: 1.4; +$line-height-relaxed: 1.6; + +$letter-spacing-tight: -0.02em; +$letter-spacing-normal: 0; +$letter-spacing-wide: 0.04em; + +/* ========================= RADIUS ========================= */ @@ -107,3 +135,17 @@ background: $color; } } + +@mixin hover_touch { + @media (hover: hover) and (pointer: fine) { + &:hover { + @content; + } + } + + @media (hover: none) and (pointer: coarse) { + &:active { + @content; + } + } +} diff --git a/src/scss/components/_avatar.scss b/src/scss/components/_avatar.scss index 02b3899..a4b88f2 100644 --- a/src/scss/components/_avatar.scss +++ b/src/scss/components/_avatar.scss @@ -41,10 +41,11 @@ background-color: $mint-600; transform: translate(15%, -15%); + &.is-online { background-color: $mint-600; } &.is-offline { background-color: $neutral-400; } - &.is-busy { background-color: $warning-text; } - &.is-away { background-color: $yellow-500; } - &.is-danger { background-color: $danger-text; } + &.is-busy { background-color: $warning-text; } + &.is-away { background-color: $yellow-500; } + &.is-danger { background-color: $danger-text; } } // Sizes @@ -137,3 +138,43 @@ box-shadow: 0 0 0 2px $neutral-0; } } + +/* Identity */ +.identity { + display: inline-flex; + align-items: center; + gap: $space-3; + min-width: 0; + + .identity-content { + display: flex; + flex-direction: column; + min-width: 0; + } + + .identity-title { + font-size: $font-size-base; + font-weight: $font-weight-semibold; + color: $neutral-900; + line-height: $line-height-snug; + } + + .identity-meta { + font-size: $font-size-sm; + color: $neutral-500; + line-height: $line-height-normal; + } +} + +.profile-identity { + display: inline-flex; + align-items: center; + text-decoration: none; + color: inherit; + + @include hover_touch { + .identity-title { + color: $neutral-700; + } + } +} diff --git a/src/scss/components/_cards.scss b/src/scss/components/_cards.scss index 403fe34..27f9959 100644 --- a/src/scss/components/_cards.scss +++ b/src/scss/components/_cards.scss @@ -124,4 +124,327 @@ gap: $space-3; } } + + // Status card + &.status-card { + max-width: 220px; + overflow: hidden; + + .status-icon-container { + position: relative; + + .status-icon { + display: flex; + align-items: center; + justify-content: flex-start; + font-size: $icon-size-display; + height: 108px; + width: 100%; + color: $neutral-500; + } + } + + .card-title { + display: flex; + width: 100%; + align-items: center; + justify-content: space-between; + font-size: $font-size-lg; + font-weight: $font-weight-bold; + text-transform: none; + border-bottom: 0; + } + + .status-name { + margin: 0; + font-size: $font-size-sm; + line-height: $line-height-normal; + color: $neutral-700; + } + + &.card-success { @include state_panel($success-text); .status-icon { color: $success-text; } } + &.card-warning { @include state_panel($warning-text); .status-icon { color: $warning-text; } } + &.card-info { @include state_panel($lavender-600); .status-icon { color: $lavender-600; } } + &.card-secondary { @include state_panel($mint-600); .status-icon { color: $mint-600; } } + &.card-danger, + &.card-error { @include state_panel($danger-text); .status-icon { color: $danger-text; } } + } + + // Metric card + &.metric-card { + max-width: 320px; + border-color: $border-color-muted; + + .card-content { + display: flex; + flex-direction: column; + gap: $space-4; + } + + .metric-card-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: $space-3; + } + + .metric-card-label { + margin: 0; + color: $neutral-500; + font-size: $font-size-sm; + font-weight: $font-weight-semibold; + text-transform: uppercase; + } + + .metric-card-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: $control-height-md; + height: $control-height-md; + color: $neutral-0; + background: $mint-600; + font-size: $icon-size-md; + border-radius: $radius-md; + } + + .metric-card-value { + margin: 0; + color: $neutral-900; + font-size: $font-size-h2; + font-weight: $font-weight-bold; + line-height: $line-height-tight; + } + + .metric-card-meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: $space-2; + color: $neutral-500; + font-size: $font-size-sm; + } + + .metric-card-delta { + color: $success-text; + font-weight: $font-weight-bold; + + &.metric-card-delta-negative { + color: $danger-text; + } + } + } + + // Horizontal card + &.card-horizontal { + max-width: none; + display: flex; + flex-direction: row; + align-items: stretch; + overflow: hidden; + + .card-media { + flex: 0 0 20%; + min-width: 80px; + max-width: 160px; + max-height: 160px; + overflow: hidden; + position: relative; + aspect-ratio: 1; + align-self: start; + background: $neutral-100; + + img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + } + } + + .card-body { + flex: 1 1 auto; + display: flex; + flex-direction: column; + padding: $space-4; + gap: $space-3; + } + + .card-title { + padding: 0; + background: transparent; + color: $neutral-900; + font-size: $font-size-lg; + font-weight: $font-weight-bold; + text-transform: none; + line-height: $line-height-snug; + border-bottom: 0; + + a { + color: inherit; + text-decoration: none; + + @include hover_touch { + text-decoration: underline; + } + } + } + + .card-content { + padding: 0; + height: auto; + + p { margin: 0; } + } + + .card-footer { + padding: 0; + background: transparent; + border-top: 0; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: $space-3; + color: $neutral-500; + font-size: $font-size-sm; + } + } + + // Login card + &.login-card { + max-width: 100%; + width: 460px; + border-color: $neutral-900; + + .login-card-header { + display: flex; + align-items: center; + justify-content: flex-start; + gap: $space-3; + } + + .login-card-logo { + display: block; + width: auto; + max-height: 40px; + } + + .login-card-logo-icon { + font-size: $icon-size-display; + color: $neutral-900; + } + + .login-card-title { + font-size: $font-size-xl; + font-weight: $font-weight-bold; + text-transform: uppercase; + } + + .login-card-form { + display: flex; + flex-direction: column; + gap: $space-4; + } + + .login-card-submit { + width: max-content; + margin-top: $space-2; + } + + .form-group { margin-bottom: 0; } + + .login-card-links { + display: flex; + justify-content: space-between; + gap: $space-3; + margin-top: $space-2; + font-size: $font-size-sm; + } + + .login-card-link { + color: $neutral-500; + text-decoration: none; + + @include hover_touch { + color: $neutral-900; + text-decoration: underline; + } + } + + .login-card-error { margin-bottom: $space-2; } + } + + // User card + &.user-card { + max-width: 320px; + + .user-card-body { + display: flex; + flex-direction: column; + align-items: center; + gap: $space-4; + padding: $space-5; + text-align: center; + } + + .identity { + flex-direction: column; + align-items: center; + gap: $space-4; + + .avatar { + width: 64px; + height: 64px; + font-size: $font-size-xl; + } + + .identity-content { + align-items: center; + text-align: center; + } + } + + .user-card-role { + color: $neutral-500; + font-size: $font-size-sm; + margin-top: $space-1; + } + + .user-card-actions { + display: flex; + gap: $space-2; + } + } + + &.user-card-compact { + max-width: none; + + .user-card-body { + flex-direction: row; + justify-content: space-between; + align-items: center; + padding: $space-3 $space-4; + text-align: left; + } + + .identity { + flex-direction: row; + gap: $space-3; + + .avatar { + width: 38px; + height: 38px; + font-size: $font-size-sm; + } + + .identity-content { + align-items: flex-start; + } + } + + .user-card-actions { + display: flex; + gap: $space-1; + } + } } diff --git a/src/scss/components/_navigation-shell.scss b/src/scss/components/_navigation-shell.scss index baa3daa..4e8bd6e 100644 --- a/src/scss/components/_navigation-shell.scss +++ b/src/scss/components/_navigation-shell.scss @@ -144,3 +144,33 @@ display: inline-flex; } } + +.navigation-footer { + display: flex; + align-items: center; + gap: $space-2; + margin-top: auto; + padding-top: $space-4; + border-top: $border-width-base $border-style-base $border-color-muted; + + .profile-identity { + flex: 1 1 auto; + min-width: 0; + } + + .identity { + min-width: 0; + + .identity-content { + min-width: 0; + overflow: hidden; + } + + .identity-title, + .identity-meta { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } +} diff --git a/src/scss/components/_progress.scss b/src/scss/components/_progress.scss index 16ba7de..88126c5 100644 --- a/src/scss/components/_progress.scss +++ b/src/scss/components/_progress.scss @@ -85,3 +85,32 @@ font-size: 18px; } } + +/* Usage meter */ +.usage-meter { + width: 100%; + max-width: 600px; + + .usage-meter-title { + display: flex; + align-items: center; + justify-content: space-between; + gap: $space-3; + margin: 0 0 $space-3; + font-size: $font-size-lg; + font-weight: $font-weight-bold; + color: $neutral-900; + } + + .usage-meter-value { + font-size: $font-size-base; + font-weight: $font-weight-semibold; + color: $neutral-500; + } + + .usage-meter-meta { + margin: $space-2 0 0; + color: $neutral-500; + font-size: $font-size-sm; + } +} diff --git a/src/scss/components/_repeater.scss b/src/scss/components/_repeater.scss new file mode 100644 index 0000000..68541af --- /dev/null +++ b/src/scss/components/_repeater.scss @@ -0,0 +1,51 @@ +/* ========================= + Repeater +========================= */ + +@use "../kit-deps" as *; + +.repeater { + .repeater-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: $space-2; + margin-bottom: $space-3; + } + + .repeater-title { + font-size: $font-size-base; + font-weight: $font-weight-semibold; + color: $neutral-900; + } + + .repeater-list { + display: grid; + gap: $space-2; + } + + .repeater-item { + @include hard_panel($border-color-muted); + padding: $space-3; + display: grid; + grid-template-columns: 1fr auto; + gap: $space-3; + align-items: start; + } + + .repeater-item-body { + width: 100%; + } + + .repeater-item-actions { + display: flex; + align-items: center; + gap: $space-2; + padding-top: $space-6; + } + + &.repeater-disabled { + opacity: 0.6; + pointer-events: none; + } +} diff --git a/src/scss/components/_timeline.scss b/src/scss/components/_timeline.scss index e379dbd..108a485 100644 --- a/src/scss/components/_timeline.scss +++ b/src/scss/components/_timeline.scss @@ -140,3 +140,51 @@ color: $neutral-600; font-size: 13px; } + +/* Activity log */ +.activity-log { + display: grid; + width: 100%; + max-width: 720px; + @include hard_panel($border-color-muted, $border-width-accent); + + .activity-log-row { + display: grid; + grid-template-columns: minmax(120px, 0.24fr) minmax(0, 1fr) auto; + gap: $space-3; + align-items: center; + padding: $space-3 $space-4; + border-bottom: $border-width-base solid $border-color-muted; + transition: background $motion-base $motion-ease; + + &:last-child { + border-bottom: 0; + } + + @include hover_touch { + background: $surface-panel-strong; + } + } + + .activity-log-time { + color: $neutral-500; + font-family: $font-family-base; + font-size: $font-size-xs; + } + + .activity-log-title { + color: $neutral-900; + font-size: $font-size-sm; + font-weight: $font-weight-semibold; + line-height: $line-height-normal; + } +} + +@media (max-width: 640px) { + .activity-log { + .activity-log-row { + grid-template-columns: 1fr; + gap: $space-2; + } + } +} diff --git a/src/scss/components/_toolbar.scss b/src/scss/components/_toolbar.scss new file mode 100644 index 0000000..5e9fcb0 --- /dev/null +++ b/src/scss/components/_toolbar.scss @@ -0,0 +1,39 @@ +/* ========================= + Toolbar +========================= */ + +@use "../kit-deps" as *; + +.toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: $space-3; + width: 100%; + padding: $space-3; + @include hard_panel($border-color-muted, $border-width-accent); + + .toolbar-group { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: $space-2; + min-width: 0; + } + + .toolbar-title { + margin: 0; + font-size: $font-size-lg; + font-weight: $font-weight-bold; + line-height: $line-height-base; + text-transform: uppercase; + color: $neutral-900; + } + + .toolbar-meta { + display: block; + color: $neutral-500; + font-size: $font-size-sm; + } +} diff --git a/src/scss/kit.scss b/src/scss/kit.scss index ece7901..82ca5aa 100644 --- a/src/scss/kit.scss +++ b/src/scss/kit.scss @@ -37,6 +37,8 @@ @use "components/lists"; @use "components/empty-state"; @use "components/skeleton"; +@use "components/toolbar"; +@use "components/repeater"; @use "components/tooltip"; @use "components/dropdown"; @use "components/advanced-select"; diff --git a/src/vue/components/GnActivityLog.js b/src/vue/components/GnActivityLog.js new file mode 100644 index 0000000..da048ed --- /dev/null +++ b/src/vue/components/GnActivityLog.js @@ -0,0 +1,19 @@ +import { defineComponent, h } from "vue"; +import { cx } from "../utils.js"; + +export default defineComponent({ + name: "GnActivityLog", + inheritAttrs: false, + props: { + items: { type: Array, default: () => [] } + }, + setup(props, { attrs, slots }) { + return () => h("div", { ...attrs, class: cx("activity-log", attrs.class) }, props.items.map(item => h("div", { + class: "activity-log-row" + }, [ + h("time", { class: "activity-log-time" }, item.time), + h("span", { class: "activity-log-title" }, slots[item.key]?.({ item }) || item.title), + slots.actions && h("span", {}, slots.actions({ item })) + ]))); + } +}); diff --git a/src/vue/components/GnAvatar.js b/src/vue/components/GnAvatar.js index aff5e8e..a2b9ecb 100644 --- a/src/vue/components/GnAvatar.js +++ b/src/vue/components/GnAvatar.js @@ -31,6 +31,7 @@ const statusClass = () => { switch (props.status) { + case "online": return "is-online"; case "offline": return "is-offline"; case "busy": return "is-busy"; case "away": return "is-away"; diff --git a/src/vue/components/GnHorizontalCard.js b/src/vue/components/GnHorizontalCard.js new file mode 100644 index 0000000..51f613d --- /dev/null +++ b/src/vue/components/GnHorizontalCard.js @@ -0,0 +1,38 @@ +import { defineComponent, h } from "vue"; +import { cx, iconNode } from "../utils.js"; + +export default defineComponent({ + name: "GnHorizontalCard", + inheritAttrs: false, + props: { + image: { type: String, default: "" }, + title: { type: String, default: "" }, + titleHref: { type: String, default: "" }, + icon: { type: String, default: "" } + }, + setup(props, { attrs, slots }) { + const titleNode = () => { + const content = [iconNode(props.icon), slots.title?.() || props.title]; + + if (props.titleHref) { + return h("a", { href: props.titleHref, class: "card-title-link" }, content); + } + + return content; + }; + + return () => h("article", { + ...attrs, + class: cx("card card-horizontal", attrs.class) + }, [ + props.image && h("div", { class: "card-media" }, [ + h("img", { src: props.image, alt: "" }) + ]), + h("div", { class: "card-body" }, [ + (props.title || props.icon || slots.title) && h("h3", { class: "card-title" }, titleNode()), + h("div", { class: "card-content" }, slots.default?.()), + slots.footer && h("footer", { class: "card-footer" }, slots.footer()) + ]) + ]); + } +}); diff --git a/src/vue/components/GnIdentity.js b/src/vue/components/GnIdentity.js new file mode 100644 index 0000000..20aee2f --- /dev/null +++ b/src/vue/components/GnIdentity.js @@ -0,0 +1,20 @@ +import { defineComponent, h } from "vue"; +import GnAvatar from "./GnAvatar.js"; + +export default defineComponent({ + name: "GnIdentity", + props: { + title: { type: String, required: true }, + meta: { type: String, default: "" }, + avatar: { type: Object, default: () => ({}) } + }, + setup(props, { slots }) { + return () => h("span", { class: "identity" }, [ + slots.avatar?.() || h(GnAvatar, props.avatar), + h("span", { class: "identity-content" }, [ + h("span", { class: "identity-title" }, slots.title?.() || props.title), + (props.meta || slots.meta) && h("span", { class: "identity-meta" }, slots.meta?.() || props.meta) + ]) + ]); + } +}); diff --git a/src/vue/components/GnLoginCard.js b/src/vue/components/GnLoginCard.js new file mode 100644 index 0000000..b03fa73 --- /dev/null +++ b/src/vue/components/GnLoginCard.js @@ -0,0 +1,89 @@ +import { defineComponent, h, ref } from "vue"; +import { cx, iconNode } from "../utils.js"; +import GnInput from "./GnInput.js"; +import GnButton from "./GnButton.js"; +import GnCheckbox from "./GnCheckbox.js"; +import GnAlert from "./GnAlert.js"; + +export default defineComponent({ + name: "GnLoginCard", + props: { + title: { type: String, default: "Sign in" }, + logoSrc: { type: String, default: "" }, + logoIcon: { type: String, default: "" }, + usernameLabel: { type: String, default: "Username" }, + usernameIcon: { type: String, default: "ph-user" }, + passwordLabel: { type: String, default: "Password" }, + passwordIcon: { type: String, default: "ph-lock-key" }, + rememberMe: { type: Boolean, default: false }, + rememberLabel: { type: String, default: "Remember me" }, + submitText: { type: String, default: "Sign in" }, + submitVariant: { type: String, default: "secondary" }, + loading: { type: Boolean, default: false }, + error: { type: String, default: "" }, + forgotHref: { type: String, default: "#" }, + forgotText: { type: String, default: "Forgot password?" }, + signupHref: { type: String, default: "#" }, + signupText: { type: String, default: "Create account" } + }, + emits: ["submit"], + setup(props, { emit }) { + const username = ref(""); + const password = ref(""); + const remember = ref(false); + + const onSubmit = (event) => { + event.preventDefault(); + emit("submit", { + username: username.value, + password: password.value, + remember: remember.value + }); + }; + + return () => h("article", { class: "card login-card" }, [ + (props.title || props.logoSrc || props.logoIcon) && h("header", { class: cx("card-title", "login-card-header") }, [ + props.logoSrc && h("img", { src: props.logoSrc, alt: "", class: "login-card-logo" }), + props.logoIcon && !props.logoSrc && iconNode(props.logoIcon, "login-card-logo-icon"), + props.title && h("span", { class: "login-card-title" }, props.title) + ]), + h("div", { class: "card-content" }, [ + props.error && h(GnAlert, { variant: "danger", class: "login-card-error" }, () => props.error), + h("form", { class: "login-card-form", onSubmit }, [ + h(GnInput, { + modelValue: username.value, + "onUpdate:modelValue": (v) => { username.value = v; }, + label: props.usernameLabel, + icon: props.usernameIcon, + type: "text", + autocomplete: "username" + }), + h(GnInput, { + modelValue: password.value, + "onUpdate:modelValue": (v) => { password.value = v; }, + label: props.passwordLabel, + icon: props.passwordIcon, + type: "password", + autocomplete: "current-password" + }), + props.rememberMe && h(GnCheckbox, { + modelValue: remember.value, + "onUpdate:modelValue": (v) => { remember.value = v; } + }, () => props.rememberLabel), + h(GnButton, { + variant: props.submitVariant, + loading: props.loading, + disabled: props.loading, + type: "submit", + icon: "ph-sign-in", + class: "login-card-submit" + }, () => props.submitText), + (props.forgotHref || props.signupHref) && h("div", { class: "login-card-links" }, [ + props.forgotHref && h("a", { href: props.forgotHref, class: "login-card-link" }, props.forgotText), + props.signupHref && h("a", { href: props.signupHref, class: "login-card-link" }, props.signupText) + ]) + ]) + ]) + ]); + } +}); diff --git a/src/vue/components/GnMetricCard.js b/src/vue/components/GnMetricCard.js new file mode 100644 index 0000000..4af09f9 --- /dev/null +++ b/src/vue/components/GnMetricCard.js @@ -0,0 +1,32 @@ +import { defineComponent, h } from "vue"; +import { cx, iconNode } from "../utils.js"; + +export default defineComponent({ + name: "GnMetricCard", + inheritAttrs: false, + props: { + label: { type: String, required: true }, + value: { type: [String, Number], required: true }, + icon: { type: String, default: "ph-chart-line-up" }, + delta: { type: String, default: "" }, + negative: { type: Boolean, default: false }, + meta: { type: String, default: "" } + }, + setup(props, { attrs, slots }) { + return () => h("article", { ...attrs, class: cx("card metric-card", attrs.class) }, [ + h("div", { class: "card-content" }, [ + h("div", { class: "metric-card-header" }, [ + h("p", { class: "metric-card-label" }, slots.label?.() || props.label), + h("span", { class: "metric-card-icon" }, [iconNode(props.icon)]) + ]), + h("p", { class: "metric-card-value" }, slots.value?.() || props.value), + (props.delta || props.meta || slots.meta) && h("div", { class: "metric-card-meta" }, [ + props.delta && h("span", { + class: cx("metric-card-delta", { "metric-card-delta-negative": props.negative }) + }, props.delta), + slots.meta?.() || props.meta + ]) + ]) + ]); + } +}); diff --git a/src/vue/components/GnNavigationShell.js b/src/vue/components/GnNavigationShell.js index 44400d1..8d6a3f1 100644 --- a/src/vue/components/GnNavigationShell.js +++ b/src/vue/components/GnNavigationShell.js @@ -44,7 +44,8 @@ class: cx("navigation-sidebar", { "is-open": isOpen.value }) }, [ props.brand && h("div", { class: "navigation-brand navigation-brand-sidebar" }, props.brand || slots.brand?.()), - h("nav", { class: "navigation-menu" }, slots.menu?.()) + h("nav", { class: "navigation-menu" }, slots.menu?.()), + slots.footer && h("footer", { class: "navigation-footer" }, slots.footer()) ]), h("div", { class: cx("navigation-backdrop", { "is-visible": isOpen.value }), diff --git a/src/vue/components/GnRepeater.js b/src/vue/components/GnRepeater.js new file mode 100644 index 0000000..b0b2d3e --- /dev/null +++ b/src/vue/components/GnRepeater.js @@ -0,0 +1,109 @@ +import { defineComponent, h, computed } from "vue"; +import { cx, iconNode } from "../utils.js"; + +let _id = 0; +function nextId() { + return `repeater-item-${++_id}`; +} + +export default defineComponent({ + name: "GnRepeater", + inheritAttrs: false, + props: { + modelValue: { type: Array, default: () => [] }, + label: { type: String, default: "Items" }, + addLabel: { type: String, default: "Add" }, + addIcon: { type: String, default: "ph-plus" }, + removeLabel: { type: String, default: "Remove" }, + removeIcon: { type: String, default: "ph-trash" }, + minItems: { type: Number, default: 1 }, + maxItems: { type: Number, default: 0 }, + itemFactory: { + type: Function, + default: () => () => ({ id: nextId() }) + }, + disabled: { type: Boolean, default: false } + }, + emits: ["update:modelValue", "add", "remove"], + setup(props, { attrs, emit, slots }) { + const canAdd = computed(() => { + if (props.disabled) return false; + if (props.maxItems > 0 && props.modelValue.length >= props.maxItems) return false; + return true; + }); + + function canRemove(index) { + if (props.disabled) return false; + if (props.modelValue.length <= props.minItems) return false; + return true; + } + + function add() { + if (!canAdd.value) return; + const item = props.itemFactory(); + const next = [...props.modelValue, item]; + emit("update:modelValue", next); + emit("add", item); + } + + function remove(index) { + if (!canRemove(index)) return; + const removed = props.modelValue[index]; + const next = props.modelValue.filter((_, i) => i !== index); + emit("update:modelValue", next); + emit("remove", removed); + } + + return () => { + const headerSlot = slots.header?.({ add, canAdd: canAdd.value }); + const header = headerSlot || h("div", { class: "repeater-header" }, [ + h("span", { class: "repeater-title" }, props.label), + h("button", { + type: "button", + class: cx("btn", "btn-secondary", "btn-sm", "with-icon"), + disabled: !canAdd.value, + onClick: add + }, [ + iconNode(props.addIcon), + props.addLabel + ]) + ]); + + const items = props.modelValue.map((item, index) => { + const itemKey = item?.id ?? item?.key ?? index; + const removable = canRemove(index); + const scope = { item, index, remove: () => remove(index), canRemove: removable }; + + const body = slots.item?.(scope); + const actions = slots.actions?.(scope); + + const defaultActions = !slots.actions && h("button", { + type: "button", + class: cx("btn-icon", "btn-icon-sm"), + "aria-label": props.removeLabel, + disabled: !removable, + onClick: (e) => { + e.stopPropagation(); + remove(index); + } + }, [iconNode(props.removeIcon)]); + + return h("div", { + class: "repeater-item", + key: itemKey + }, [ + h("div", { class: "repeater-item-body" }, body), + (actions || defaultActions) && h("div", { class: "repeater-item-actions" }, actions || [defaultActions]) + ]); + }); + + return h("div", { + ...attrs, + class: cx("repeater", { "repeater-disabled": props.disabled }, attrs.class) + }, [ + header, + h("div", { class: "repeater-list" }, items) + ]); + }; + } +}); diff --git a/src/vue/components/GnRouterTabs.js b/src/vue/components/GnRouterTabs.js new file mode 100644 index 0000000..a1150c0 --- /dev/null +++ b/src/vue/components/GnRouterTabs.js @@ -0,0 +1,77 @@ +/** + * GnRouterTabs — Router-aware tab switcher. + * + * Wraps GnTabs and drives active state from the current vue-router route. + * When a tab is activated, the component calls router.push(item.to) + * instead of emitting update:modelValue. + */ +import { computed, defineComponent, h } from "vue"; +import { tryUseRouter, tryUseRoute, isRouteActive } from "../composables/useVueRouter.js"; +import GnTabs from "./GnTabs.js"; + +export default defineComponent({ + name: "GnRouterTabs", + props: { + items: { type: Array, required: true }, + compact: { type: Boolean, default: false }, + vertical: { type: Boolean, default: false }, + ariaLabel: { type: String, default: "Tabs" }, + activeMatch: { type: String, default: "prefix" } + }, + setup(props, { slots }) { + const router = tryUseRouter(); + const route = tryUseRoute(); + + const hasRouter = Boolean(router && route); + + if (!hasRouter && typeof process !== "undefined" && process.env && process.env.NODE_ENV !== "production") { + // eslint-disable-next-line no-console + console.warn("[vmk-ui-kit] GnRouterTabs requires vue-router. Falling back to standard tabs."); + } + + const tabItems = computed(() => props.items.map(item => ({ + id: item.id, + label: item.label, + icon: item.icon, + disabled: item.disabled, + to: item.to + }))); + + const activeId = computed(() => { + if (!hasRouter) { + return tabItems.value.find(item => !item.disabled)?.id || ""; + } + + const matched = tabItems.value.find(item => { + if (item.disabled || !item.to) { + return false; + } + + return isRouteActive(route, item.to, props.activeMatch); + }); + + return matched?.id || tabItems.value.find(item => !item.disabled)?.id || ""; + }); + + const onUpdate = id => { + if (!hasRouter) { + return; + } + + const item = tabItems.value.find(i => i.id === id); + + if (item && item.to) { + router.push(item.to); + } + }; + + return () => h(GnTabs, { + modelValue: activeId.value, + items: tabItems.value, + compact: props.compact, + vertical: props.vertical, + ariaLabel: props.ariaLabel, + "onUpdate:modelValue": onUpdate + }, slots); + } +}); diff --git a/src/vue/components/GnStatusCard.js b/src/vue/components/GnStatusCard.js new file mode 100644 index 0000000..e32e5a4 --- /dev/null +++ b/src/vue/components/GnStatusCard.js @@ -0,0 +1,26 @@ +import { defineComponent, h } from "vue"; +import { cx, iconNode, normalizeVariant } from "../utils.js"; + +export default defineComponent({ + name: "GnStatusCard", + inheritAttrs: false, + props: { + title: { type: String, required: true }, + text: { type: String, default: "" }, + icon: { type: String, default: "ph-stack" }, + variant: { type: String, default: "primary" } + }, + setup(props, { attrs, slots }) { + const variant = normalizeVariant(props.variant); + + return () => h("article", { ...attrs, class: cx("card status-card", `card-${variant}`, attrs.class) }, [ + h("span", { class: "card-title" }, slots.title?.() || props.title), + h("div", { class: "card-content" }, [ + h("div", { class: "status-icon-container" }, [ + h("div", { class: "status-icon" }, slots.icon?.() || [iconNode(props.icon)]) + ]), + (props.text || slots.default) && h("p", { class: "status-name" }, slots.default?.() || props.text) + ]) + ]); + } +}); diff --git a/src/vue/components/GnTabs.js b/src/vue/components/GnTabs.js index 5ede037..69caa63 100644 --- a/src/vue/components/GnTabs.js +++ b/src/vue/components/GnTabs.js @@ -1,13 +1,16 @@ /** - * GnTabs - Accessible tab interface for Vue. + * GnTabs - Accessible tab switcher with keyboard navigation. * - * @slots default - GnTab components - * @slots panels - GnTabPanel components (optional) - * @emits change + * Supports two modes: + * - `items` mode (reference API): pass an array of { id, label, icon?, disabled? } + * and use named slots for panels. + * - child mode: place GnTab / GnTabPanel children directly. + * + * @emits update:modelValue, change */ import { defineComponent, h, provide, ref, computed, onMounted, watch } from "vue"; -import { cx, normalizeVariant } from "../utils.js"; +import { cx, iconNode } from "../utils.js"; export const tabsKey = Symbol("vmk-ui-kit-tabs"); @@ -15,8 +18,11 @@ name: "GnTabs", props: { modelValue: { type: [String, Number], default: null }, + items: { type: Array, default: null }, variant: { type: String, default: "default" }, - vertical: { type: Boolean, default: false } + compact: { type: Boolean, default: false }, + vertical: { type: Boolean, default: false }, + ariaLabel: { type: String, default: "Tabs" } }, emits: ["update:modelValue", "change"], setup(props, { emit, slots }) { @@ -25,6 +31,13 @@ let tabItems = []; let panelItems = []; + const hasItems = computed(() => Array.isArray(props.items) && props.items.length > 0); + + const activeId = computed(() => { + if (!hasItems.value) return activeKey.value; + return props.modelValue || props.items.find(item => !item.disabled)?.id || props.items[0]?.id; + }); + const registerTab = (key, label) => { tabItems.push({ key, label }); }; @@ -42,7 +55,7 @@ const isActive = key => activeKey.value === key; const classes = computed(() => cx("tabs", { - "tabs-compact": props.variant === "compact", + "tabs-compact": props.compact || props.variant === "compact", "tabs-vertical": props.vertical })); @@ -69,12 +82,86 @@ isActive }); - return () => h("div", { class: classes.value }, [ + // Items mode: matches the gnexus-ui-kit reference API. + const renderItemsMode = () => { + const enabledItems = () => props.items.filter(item => !item.disabled); + + const activate = item => { + if (!item.disabled) { + emit("update:modelValue", item.id); + emit("change", item.id); + } + }; + + const move = (item, direction) => { + const items = enabledItems(); + const index = items.findIndex(enabled => enabled.id === item.id); + const next = items[(index + direction + items.length) % items.length]; + activate(next); + }; + + const handleKeydown = (event, item) => { + if (event.key === "ArrowRight" || event.key === "ArrowDown") { + event.preventDefault(); + move(item, 1); + } else if (event.key === "ArrowLeft" || event.key === "ArrowUp") { + event.preventDefault(); + move(item, -1); + } else if (event.key === "Home") { + event.preventDefault(); + activate(enabledItems()[0]); + } else if (event.key === "End") { + event.preventDefault(); + const items = enabledItems(); + activate(items[items.length - 1]); + } + }; + + return h("div", { class: classes.value }, [ + h("div", { + class: "tabs-list", + role: "tablist", + "aria-label": props.ariaLabel + }, props.items.map(item => { + const active = item.id === activeId.value; + const panelId = `${item.id}-panel`; + + return h("button", { + class: cx("tab", { "tab-active": active }), + type: "button", + role: "tab", + "aria-selected": active ? "true" : "false", + "aria-controls": panelId, + "aria-disabled": item.disabled ? "true" : undefined, + tabindex: active ? "0" : "-1", + onClick: () => activate(item), + onKeydown: event => handleKeydown(event, item) + }, [ + iconNode(item.icon), + item.label + ]); + })), + h("div", { class: "tabs-panels" }, props.items.map(item => { + const active = item.id === activeId.value; + + return h("div", { + id: `${item.id}-panel`, + class: cx("tab-panel", { "tab-panel-active": active }), + role: "tabpanel", + hidden: !active + }, slots[item.id]?.({ item, active }) || (active && slots.default?.({ item, active }))); + })) + ]); + }; + + // Child mode: GnTab / GnTabPanel children register themselves via provide. + const renderChildMode = () => h("div", { class: classes.value }, [ h("div", { ref: tablistRef, class: "tabs-list", role: "tablist", ariaOrientation: props.vertical ? "vertical" : "horizontal", + "aria-label": props.ariaLabel, onKeydown: event => { if (event.key === "ArrowRight" || event.key === "ArrowDown") { event.preventDefault(); @@ -93,5 +180,7 @@ }, slots.default?.()), slots.panels && h("div", { class: "tabs-panels" }, slots.panels()) ]); + + return () => hasItems.value ? renderItemsMode() : renderChildMode(); } }); diff --git a/src/vue/components/GnToolbar.js b/src/vue/components/GnToolbar.js new file mode 100644 index 0000000..e8aedd0 --- /dev/null +++ b/src/vue/components/GnToolbar.js @@ -0,0 +1,22 @@ +import { defineComponent, h } from "vue"; +import { cx } from "../utils.js"; + +export default defineComponent({ + name: "GnToolbar", + inheritAttrs: false, + props: { + title: { type: String, default: "" }, + meta: { type: String, default: "" } + }, + setup(props, { attrs, slots }) { + return () => h("div", { ...attrs, class: cx("toolbar", attrs.class) }, [ + h("div", { class: "toolbar-group" }, [ + h("div", {}, [ + (props.title || slots.title) && h("h3", { class: "toolbar-title" }, slots.title?.() || props.title), + (props.meta || slots.meta) && h("span", { class: "toolbar-meta" }, slots.meta?.() || props.meta) + ]) + ]), + (slots.default || slots.actions) && h("div", { class: "toolbar-group" }, slots.actions?.() || slots.default?.()) + ]); + } +}); diff --git a/src/vue/components/GnUsageMeter.js b/src/vue/components/GnUsageMeter.js new file mode 100644 index 0000000..03d3471 --- /dev/null +++ b/src/vue/components/GnUsageMeter.js @@ -0,0 +1,26 @@ +import { defineComponent, h } from "vue"; +import GnProgress from "./GnProgress.js"; + +export default defineComponent({ + name: "GnUsageMeter", + props: { + title: { type: String, required: true }, + value: { type: Number, required: true }, + max: { type: Number, default: 100 }, + meta: { type: String, default: "" } + }, + setup(props, { slots }) { + return () => { + const percent = Math.max(0, Math.min(100, Math.round((props.value / props.max) * 100))); + + return h("section", { class: "usage-meter" }, [ + h("h3", { class: "usage-meter-title" }, [ + slots.title?.() || props.title, + h("span", { class: "usage-meter-value" }, `${percent}%`) + ]), + h(GnProgress, { value: props.value, max: props.max }), + (props.meta || slots.meta) && h("p", { class: "usage-meter-meta" }, slots.meta?.() || props.meta) + ]); + }; + } +}); diff --git a/src/vue/components/GnUserCard.js b/src/vue/components/GnUserCard.js new file mode 100644 index 0000000..c3a4165 --- /dev/null +++ b/src/vue/components/GnUserCard.js @@ -0,0 +1,99 @@ +import { defineComponent, h } from "vue"; +import { cx, iconNode } from "../utils.js"; +import GnAvatar from "./GnAvatar.js"; + +export default defineComponent({ + name: "GnUserCard", + props: { + name: { type: String, required: true }, + email: { type: String, default: "" }, + role: { type: String, default: "" }, + avatar: { type: Object, default: () => ({}) }, + href: { type: String, default: "" }, + compact: { type: Boolean, default: false }, + actions: { type: Array, default: () => [] } + }, + setup(props, { slots }) { + const renderAvatar = () => slots.avatar?.() || h(GnAvatar, { + ...props.avatar, + size: props.compact ? "sm" : "md" + }); + + const renderIdentity = () => h("span", { class: "identity" }, [ + renderAvatar(), + h("span", { class: "identity-content" }, [ + h("span", { class: "identity-title" }, props.name), + props.email && h("span", { class: "identity-meta" }, props.email) + ]) + ]); + + const renderActions = () => { + if (slots.actions) return slots.actions(); + if (!props.actions.length) return null; + + return h("div", { class: "user-card-actions" }, + props.actions.map(action => { + if (props.compact) { + return h("button", { + class: "btn-icon", + type: "button", + "aria-label": action.label, + onClick: action.onClick + }, [iconNode(action.icon)]); + } + + const hasIcon = !!action.icon; + const btnClass = cx("btn", "btn-sm", { + [`btn-${action.variant}`]: action.variant, + "btn-secondary": !action.variant, + "with-icon": hasIcon + }); + + return h("button", { + class: btnClass, + type: "button", + onClick: action.onClick + }, [ + hasIcon && iconNode(action.icon), + action.label + ]); + }) + ); + }; + + return () => { + const rootClass = cx("card", "user-card", { "user-card-compact": props.compact }); + const bodyChildren = []; + + if (props.href) { + bodyChildren.push( + h("a", { + class: "profile-identity", + href: props.href, + target: "_blank", + rel: "noopener noreferrer" + }, [renderIdentity()]) + ); + } else { + bodyChildren.push(renderIdentity()); + } + + if (!props.compact && props.role) { + bodyChildren.push(h("span", { class: "user-card-role" }, props.role)); + } + + if (slots.default && !props.compact) { + bodyChildren.push(h("div", { class: "user-card-extra" }, slots.default())); + } + + const actionsNode = renderActions(); + if (actionsNode) { + bodyChildren.push(actionsNode); + } + + return h("article", { class: rootClass }, [ + h("div", { class: "user-card-body" }, bodyChildren) + ]); + }; + } +}); diff --git a/src/vue/index.js b/src/vue/index.js index 9308be6..42c19a9 100644 --- a/src/vue/index.js +++ b/src/vue/index.js @@ -66,6 +66,17 @@ 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 { default as GnHorizontalCard } from "./components/GnHorizontalCard.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"; +export { default as GnLoginCard } from "./components/GnLoginCard.js"; +export { default as GnIdentity } from "./components/GnIdentity.js"; +export { default as GnActivityLog } from "./components/GnActivityLog.js"; +export { default as GnToolbar } from "./components/GnToolbar.js"; +export { default as GnRouterTabs } from "./components/GnRouterTabs.js"; +export { default as GnRepeater } from "./components/GnRepeater.js"; +export { default as GnUsageMeter } from "./components/GnUsageMeter.js"; export { useToast } from "./composables/useToast.js"; export { tryUseRouter, tryUseRoute, isRouteActive } from "./composables/useVueRouter.js"; export { components, VMKUiVue, GnexusUiVue } from "./plugin.js"; diff --git a/src/vue/plugin.js b/src/vue/plugin.js index 2247883..a94d040 100644 --- a/src/vue/plugin.js +++ b/src/vue/plugin.js @@ -63,6 +63,17 @@ import GnPagination from "./components/GnPagination.js"; import GnTimeline from "./components/GnTimeline.js"; import GnLoader from "./components/GnLoader.js"; +import GnHorizontalCard from "./components/GnHorizontalCard.js"; +import GnUserCard from "./components/GnUserCard.js"; +import GnMetricCard from "./components/GnMetricCard.js"; +import GnStatusCard from "./components/GnStatusCard.js"; +import GnLoginCard from "./components/GnLoginCard.js"; +import GnIdentity from "./components/GnIdentity.js"; +import GnActivityLog from "./components/GnActivityLog.js"; +import GnToolbar from "./components/GnToolbar.js"; +import GnRouterTabs from "./components/GnRouterTabs.js"; +import GnRepeater from "./components/GnRepeater.js"; +import GnUsageMeter from "./components/GnUsageMeter.js"; export const components = { GnButton, @@ -127,7 +138,18 @@ GnPopover, GnPagination, GnTimeline, - GnLoader + GnLoader, + GnHorizontalCard, + GnUserCard, + GnMetricCard, + GnStatusCard, + GnLoginCard, + GnIdentity, + GnActivityLog, + GnToolbar, + GnRouterTabs, + GnRepeater, + GnUsageMeter }; const plugin = { diff --git a/tests/demo-screenshot.spec.js b/tests/demo-screenshot.spec.js index a154514..ede7d86 100644 --- a/tests/demo-screenshot.spec.js +++ b/tests/demo-screenshot.spec.js @@ -33,6 +33,8 @@ { name: "drawer", label: "Drawers" }, { name: "toasts", label: "Toasts" }, { name: "breadcrumbs", label: "Breadcrumbs" }, + { name: "accordion", label: "Accordion" }, + { name: "tabs", label: "Tabs" }, { name: "choices", label: "Choice controls" }, { name: "select-textarea", label: "Select & Textarea" }, { name: "divider", label: "Divider" }, @@ -40,7 +42,9 @@ { name: "pagination", label: "Pagination" }, { name: "loader", label: "Loader" }, { name: "chips", label: "Chips" }, - { name: "avatar", label: "Avatar" } + { name: "avatar", label: "Avatar" }, + { name: "skeleton", label: "Skeleton" }, + { name: "navigation-shell", label: "Navigation Shell" } ]; test.describe("@screenshot", () => { diff --git a/tests/functional.spec.js b/tests/functional.spec.js index 6d42c0b..cfede50 100644 --- a/tests/functional.spec.js +++ b/tests/functional.spec.js @@ -15,7 +15,7 @@ }); test("accordion toggles open and closed", async ({ page }) => { - const summary = page.locator(".accordion-summary", { hasText: "Account details" }); + const summary = page.locator(".accordion-summary", { hasText: "Account details" }).first(); const details = summary.locator(".."); await expect(details).not.toHaveAttribute("open", ""); await summary.click(); @@ -47,7 +47,7 @@ }); test("tooltip shows on hover", async ({ page }) => { - const trigger = page.locator(".tooltip-trigger", { hasText: "Top" }); + const trigger = page.locator(".tooltip-trigger", { hasText: "Top" }).first(); const wrapper = trigger.locator("xpath=.."); await trigger.hover(); await expect(wrapper).toHaveClass(/is-visible/);