diff --git a/.changeset/violet-keys-burn.md b/.changeset/violet-keys-burn.md new file mode 100644 index 0000000000..0ee3870a90 --- /dev/null +++ b/.changeset/violet-keys-burn.md @@ -0,0 +1,6 @@ +--- +"@stackoverflow/stacks": minor +"@stackoverflow/stacks-svelte": minor +--- + +Update Post Summary to SHINE styles diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md index 379e87bb1b..6a0ada742f 100755 --- a/MIGRATION_GUIDE.md +++ b/MIGRATION_GUIDE.md @@ -137,6 +137,9 @@ The menu component has been updated to use new class names and structure. The fo #### Pagination - The next and previous button now uses an `ArrowRight` and `ArrowLeft` icon instead of text. To apply the new styling, use the class `.s-pagination--item__nav`. Since these buttons use icons to represent their behavior, make sure to include descriptive text for screen readers. +#### Post Summary +The Post Summary component has changed dramatically. Please refer to the docs for complete guidance. + #### Popover - The new popovers no longer include an arrow element. The `s-popover--arrow` css class has been removed, and any markup using it (e.g. `
`) should be deleted from the codebases as part of the migration. diff --git a/packages/stacks-classic/lib/components/post-summary/post-summary.a11y.test.ts b/packages/stacks-classic/lib/components/post-summary/post-summary.a11y.test.ts index bb1fe41fc5..d95515cbd2 100644 --- a/packages/stacks-classic/lib/components/post-summary/post-summary.a11y.test.ts +++ b/packages/stacks-classic/lib/components/post-summary/post-summary.a11y.test.ts @@ -1,26 +1,7 @@ -// TODO SPARK reinstate accessibility tests once component styles are updated -// import { runA11yTests } from "../../test/a11y-test-utils"; -// import testArgs from "./post-summary.test.setup"; -// import "../../index"; +import { runA11yTests } from "../../test/a11y-test-utils"; +import testArgs from "./post-summary.test.setup"; +import "../../index"; -// describe("post-summary", () => { -// // Base, sparce -// runA11yTests({ -// ...testArgs.base, -// // TODO resolve test failures -// skippedTestids: [ -// /-deleted/, -// /-ignored/, -// /-highcontrast-(light|dark)-watched/, -// ], -// }); - -// // Truncated description sizes -// runA11yTests(testArgs.sizes); - -// // Stats - answers, view hotness -// runA11yTests(testArgs.stats); - -// // Badges -// runA11yTests(testArgs.badges); -// }); +describe("post-summary", () => { + runA11yTests(testArgs); +}); diff --git a/packages/stacks-classic/lib/components/post-summary/post-summary.less b/packages/stacks-classic/lib/components/post-summary/post-summary.less index c52328047f..a6a8e75217 100644 --- a/packages/stacks-classic/lib/components/post-summary/post-summary.less +++ b/packages/stacks-classic/lib/components/post-summary/post-summary.less @@ -1,458 +1,291 @@ .s-post-summary { - // --_ps-state-* are custom properties to broadly override colors for a given post summary state - --_ps-bb: var(--su1) solid var(--bc-light); - --_ps-bg: unset; - --_ps-o: unset; - // Child components - --_ps-content-excerpt-fc: var(--_ps-state-fc, var(--fc-medium)); - --_ps-content-title-a-fc: var(--_ps-state-fc, var(--theme-post-title-color, var(--theme-link-color, var(--theme-secondary)))); - --_ps-content-title-a-fc-hover: var(--_ps-state-fc, var(--theme-post-title-color-hover, var(--theme-link-color-hover, var(--theme-secondary-500)))); - --_ps-content-title-a-fc-visited: var(--_ps-state-fc, var(--theme-post-title-color-visited, var(--theme-link-color-visited, var(--purple-500)))); - --_ps-content-title-a-fc-hover-visited: var(--_ps-state-fc, var(--theme-post-title-color-hover, var(--theme-link-color-hover, var(--purple-600)))); - --_ps-stats-ai: flex-end; - --_ps-stats-fc: var(--_ps-state-fc, var(--fc-light)); - --_ps-stats-fd: column; - --_ps-stats-w: calc(var(--su96) + var(--su12)); - // Stats item modifiers - --_ps-has-answers-bc: var(--green-400); - --_ps-has-answers-bg: unset; - --_ps-has-answers-fc: var(--green-400); - --_ps-has-accepted-answers-bc: var(--green-400); - --_ps-has-accepted-answers-bg: var(--green-400); - --_ps-has-accepted-answers-fc: var(--white); - --_ps-stats-item-emphasized-fc: var(--_ps-state-fc, var(--fc-dark)); - - // CONTEXTUAL STYLES - #stacks-internals #screen-md({ - --_ps-stats-ai: center; - --_ps-stats-fd: row; - --_ps-stats-w: auto; - - flex-direction: column; + @psx-container-sm: 28rem; + --_ps-answer-icon-fc: unset; + --_ps-content-type-bc: var(--black-200); + --_ps-content-type-bg: var(--black-050); + --_ps-content-type-fc: var(--black-600); + --_ps-stats-answers-bg: unset; + --_ps-stats-answers-fc: var(--black-400); + --_ps-stats-answers-fw: unset; + --_ps-stats-answers-icon-fc: unset; + --_ps-title-link-fc: var(--theme-secondary-600); + + // Conditional styles + .highcontrast-mode({ + &__deleted * { + --_ps-ignored-fc: var(--black-500); + } }); - // MODIFIERS - &&__minimal, - & &--answer { - --_ps-stats-ai: center; - --_ps-stats-fd: row; - --_ps-stats-w: auto; + // Note: we cannot use CSS custom properties for container query values + @container post-summary (width <= @psx-container-sm) { + .s-post-summary--sm-hide { + display: none !important; + } + .s-post-summary--sm-show { + display: flex !important; + } } - &&__minimal { - .s-post-summary--content { - width: 100%; + @container post-summary (width > @psx-container-sm) { + .s-post-summary--sm-hide { + display: flex !important; + } + .s-post-summary--sm-show { + display: none !important; } + } - flex-direction: column; + // MODIFIERS + &&__answered { + --_ps-stats-answers-bg: var(--green-400); + --_ps-stats-answers-fc: var(--white); + --_ps-stats-answers-fw: 600; + --_ps-stats-answers-icon-fc: var(--green-400); } - // VARIANTS &&__deleted, - &&__ignored { - --_ps-o: 0.75; - --_ps-has-answers-bc: var(--black-350); - --_ps-has-answers-bg: transparent; - --_ps-has-answers-fc: var(--_ps-state-fc); - --_ps-has-accepted-answers-bc: transparent; - --_ps-has-accepted-answers-bg: var(--black-150); - --_ps-has-accepted-answers-fc: var(--_ps-state-fc); - --_ps-meta-tags-tag-bg: var(--black-150); - --_ps-meta-tags-tag-fc: var(--_ps-state-fc); - --_ps-state-fc: var(--black-400); - - .s-post-summary--meta-tags { - a, // TODO: remove rule for `a` once Core replaces `.post-tag` with `.s-tag` - .post-tag, - .s-tag { - &, - &:active, - &:hover, - &:focus, - .focus-bordered { - .highcontrast-mode({ - border-color: currentColor; - }); - - background-color: var(--_ps-meta-tags-tag-bg); - color: var(--black-500); - border-color: var(--black-300); - } - } + &:has(.s-tag.s-tag__ignored) { + --_ps-ignored-bg: var(--black-100); + --_ps-ignored-fc: var(--black-400); + + &.s-post-summary__answered { + --_ps-stats-answers-bg: var(--_ps-ignored-bg); + --_ps-stats-answers-fc: var(--_ps-ignored-fc); + --_ps-stats-answers-icon-fc: var(--black-350); } - .s-user-card { - a, - .s-user-card--link, - .s-user-card--rep, - .s-user-card--time { - color: var(--_ps-state-fc); - } - - .s-badge { - filter: grayscale(100%); - } + * { + color: var(--_ps-ignored-fc) !important; } - } - &&__deleted, - &&__watched { - background-color: var(--_ps-bg); - } - &&__deleted { - --_ps-bg: var(--red-100); - --_ps-has-accepted-answers-bg: var(--black-200); - --_ps-has-accepted-answers-fc: var(--black-500); - --_ps-meta-tags-tag-bg: var(--black-200); - - .is-deleted, - .s-badge__danger.s-badge__filled { - .dark-mode({ - background-color: var(--red-600); - color: var(--white); - }); + .s-avatar { + opacity: 0.5; + } - background-color: var(--red-500); + .s-user-card--rep .s-bling:before { + background-color: var(--_ps-ignored-fc) !important; } - } - &&__watched { - &:not(.s-post-summary__deleted):not(.s-post-summary__ignored) { - --_ps-bg: var(--yellow-100); - --_ps-stats-fc: var(--black-400); - --_ps-content-title-a-fc: var(--theme-post-title-color, var(--theme-link-color, var(--theme-secondary))); - --_ps-content-title-a-fc-hover: var(--theme-post-title-color-hover, var(--theme-link-color-hover, var(--theme-secondary-500))); - --_ps-content-title-a-fc-visited: var(--theme-post-title-color-visited, var(--theme-link-color-visited, var(--purple-500))); - --_ps-content-title-a-fc-hover-visited: var(--theme-post-title-color-hover, var(--theme-link-color-hover, var(--purple-600))); - - .s-user-card { - a { - &:active, - &:hover { - color: var(--_ps-content-title-a-fc-hover); - } - - &:visited { - &:hover { - color: var(--_ps-content-title-a-fc-hover-visited); - } - - color: var(--_ps-content-title-a-fc-visited); - } - - color: var(--_ps-content-title-a-fc); - } - - .s-user-card--rep, - .s-user-card--time { - color: var(--black-500); - } - } + .s-badge, + .s-tag, + .s-post-summary--stats-bounty { + background-color: var(--_ps-ignored-bg) !important; + border-color: var(--_ps-ignored-bg) !important; + color: var(--_ps-ignored-fc) !important; } } - &:last-child { - --_ps-bb: none; + &&__deleted { + background-color: var(--red-100); + border: var(--su8) solid var(--red-100); } - // Child Elements - & & { - &--answer { - & + & { - margin-top: var(--su16); - } + // Child components + .s-tag.s-tag__watched { + --_ta-bc: var(--yellow-200); + --_ta-bg: var(--yellow-200); + --_ta-fc: var(--yellow-600); + --_ta-bc-hover: var(--yellow-200); + --_ta-bg-hover: var(--yellow-200); + } - &:before { - .highcontrast-mode({ - background: var(--black-500); - }); - - background: var(--black-250); - border-radius: var(--su8); - bottom: 0; - content: ""; - display: block; - left: 0; - position: absolute; - top: 0; - width: var(--su4); - } + // TODO SHINE complete answers styling + & &--answers { + display: flex; + flex-direction: column; + gap: var(--su16); + padding-top: calc(var(--su8) + var(--su2)); // 10px + } - margin: var(--su16) 1em 0 1em; - padding: 0.5em 0 0.5em calc(1em + var(--su4)); - position: relative; + & &--answer { + &__accepted { + --_ps-answer-icon-fc: var(--green-400); } - &--answer-excerpt { - .v-truncate4; - color: var(--black-500); - margin-bottom: var(--su8); + .s-post-summary--stats-answers--icon { + color: var(--_ps-answer-icon-fc); } - &--content { - > *:not(.s-post-summary--content-menu-button):not(.s-post-summary--meta):not(.s-popover) { - opacity: var(--_ps-o); - } - - flex-grow: 1; - max-width: 100%; - } + border-left: var(--su4) solid var(--black-200); + display: flex; + flex-direction: column; + gap: var(--su6); + padding-left: var(--su8); + } - &--content-excerpt { - &.s-post-summary--content-excerpt { - &__sm { - .v-truncate1; - } + & &--content { + display: flex; + flex-direction: column; + gap: var(--su4); + width: 100%; + } - &__md { - .v-truncate3; - } + & &--content-meta { + align-items: center; + color: var(--black-400); + display: flex; + flex-wrap: wrap; + font-size: var(--fs-caption); + gap: var(--su6); + margin-bottom: var(--su4); + } - &__lg { - .v-truncate4; - } - } + & &--content-type { + &:focus-visible { + .focus-styles(); + } - .break-word; - .v-truncate2; - color: var( --_ps-content-excerpt-fc); - font-family: var(--theme-post-body-font-family, var(--theme-body-font-family)); - margin-top: var(--sun2); - margin-bottom: var(--su8); + &:hover { + --_ps-content-type-bc: var(--black-150); + --_ps-content-type-bg: var(--black-100); + --_ps-content-type-fc: var(--black-600); } - &--content-menu-button { - .svg-icon { - margin: 0 !important; - } + border: var(--su-static1) solid var(--_ps-content-type-bc); + background-color: var(--_ps-content-type-bg); + color: var(--_ps-content-type-fc); - &, - &.s-btn { // To override .s-btn class attributes - padding: var(--su8); - position: absolute; - } + display: flex; + align-items: center; + gap: var(--su4); + padding: 0 var(--su4); + font-size: var(--fs-caption); + } - right: var(--su8); - top: var(--su8); - } + & &--excerpt { + line-height: 1.25rem; // TODO use a standard line-height variable + margin-bottom: 0; + } - &--content-title { - a { - &:active, - &:hover { - color: var(--_ps-content-title-a-fc-hover); - } - &:visited { - &:hover { - color: var(--_ps-content-title-a-fc-hover-visited); - } - - color: var(--_ps-content-title-a-fc-visited); - } - - .break-word; - color: var(--_ps-content-title-a-fc); - font-family: var(--theme-post-title-font-family, var(--theme-body-font-family)); - } + & &--stats { + &.s-post-summary--sm-hide { + .s-post-summary--stats-answers { + background-color: var(--_ps-stats-answers-bg); + color: var(--_ps-stats-answers-fc); + font-weight: var(--_ps-stats-answers-fw); - .iconShield { - color: var(--fc-light); + align-items: center; + display: flex; + gap: var(--su4); + justify-content: center; + padding: var(--su4); } - .svg-icon { - position: relative; - top: var(--sun1); - vertical-align: text-bottom; // Optical alignment + .s-post-summary--stats-bounty { + align-items: center; + background-color: var(--blue-400); + color: var(--white); + display: flex; + gap: var(--su2); + font-weight: 600; + justify-content: center; + padding: var(--su4); } - .break-word; - display: block; - font-size: var(--fs-body3); - font-weight: normal; - line-height: var(--lh-md); - margin-bottom: 0.3365rem; - margin-top: -0.125rem; // Optical alignment to compensate for title's containing block - padding-right: var(--su24); - } - - &--content-type { - .svg-icon { - color: var(--fc-light); - margin-left: var(--sun2); + .s-post-summary--stats-votes { + align-items: center; + aspect-ratio: 1/1; + border: var(--su1) solid var(--black-200); + display: flex; + justify-content: center; + font-size: var(--fs-body2); + font-weight: 600; + margin-bottom: var(--su2); + padding: var(--su4); + width: calc(var(--su48) + var(--su8)); // 3.5rem } - color: var(--fc-medium); - margin-bottom: var(--su4); + flex-direction: column; } - &--meta { - > *:not(.s-post-summary--meta-tags):not(.s-user-card) > * { - opacity: var(--_ps-o); - } - - .s-user-card { - > *:not(.magic-popup) { - opacity: var(--_ps-o); - } - - flex-wrap: wrap; - justify-content: flex-end; - margin-left: auto; + &.s-post-summary--sm-show { + .s-post-summary--stats-answers--icon { + color: var(--_ps-stats-answers-icon-fc); } align-items: center; - column-gap: var(--su6); - display: flex; - flex-wrap: wrap; - justify-content: space-between; - row-gap: var(--su8); + justify-content: center; + padding: var(--su4); } - &--meta-tags { - > ul > li > a, - > a, - .post-tag, - .s-tag { - opacity: var(--_ps-o); - } - + .s-post-summary--stats-bounty { + align-items: center; + justify-content: center; + background-color: var(--blue-400); + color: var(--white); display: flex; - flex-wrap: wrap; - gap: var(--su4); + gap: var(--su1); + padding: 0 calc(var(--su4) - var(--su1)); } - &--stats { - > *:not(.s-badge__danger) { - opacity: var(--_ps-o); - } - - align-items: var(--_ps-stats-ai); - color: var(--_ps-stats-fc); - flex-direction: var(--_ps-stats-fd); - width: var(--_ps-stats-w); + display: flex; + gap: var(--su6); + font-size: var(--fs-caption); + } - display: flex; - flex-shrink: 0; - flex-wrap: wrap; - font-size: var(--fs-body1); - gap: var(--su6); - margin-bottom: var(--su4); - margin-right: var(--su16); + & &--stats-item { + &:before { + aspect-ratio: 1/1; + background-color: var(--black-300); + content: ""; + display: block; + height: var(--su4); } - &--stats-item { - &:not(.s-badge) { - &.is-deleted { - color: var(--white); - } - - align-items: center; - border: var(--su1) solid transparent; - display: inline-flex; - gap: 0.3em; - justify-content: center; - white-space: nowrap; - } - - &.s-badge { - font-size: var(--fs-body1); - line-height: var(--lh-md); - padding: var(--su2) var(--su4); - } - - &.has-answers, - &.has-bounty, // TODO DEPRECATED: Remove .has-bounty styling - &.is-archived, - &.is-closed, - &.is-deleted, - &.is-draft, - &.is-pinned, - &.is-published, - &.is-review { - border-radius: var(--br-md); - padding: var(--su2) var(--su4); - } - - &.has-answers { - &.has-accepted-answer { - background-color: var(--_ps-has-accepted-answers-bg); - border-color: var(--_ps-has-accepted-answers-bc); - color: var(--_ps-has-accepted-answers-fc); - } - - background-color: var(--_ps-has-answers-bg); - border: var(--su1) solid var(--_ps-has-answers-bc); - color: var(--_ps-has-answers-fc); - } - - &.has-bounty { // TODO DEPRECATED: Remove and replace with references with `s-badge s-badge__bounty` - background-color: var(--blue-500); - color: var(--white); - } - - // Hotness - &.is-warm { - color: var(--_ps-state-fc, var(--yellow-500)); - } - - &.is-hot { - color: var(--_ps-state-fc, var(--orange-500)); - } - - &.is-supernova { - color: var(--_ps-state-fc, var(--red-500)); - } - - // Status - &.is-archived { // TODO DEPRECATED: Remove and replace with references with `s-badge s-badge__muted s-badge__icon` - background-color: var(--black-225); - border-color: var(--black-500); - color: var(--black-600); - } - - &.is-closed { // TODO DEPRECATED: Remove and replace with references with `s-badge s-badge__danger s-badge__icon` - background-color: var(--red-300); - border-color: var(--red-500); - color: var(--red-600); - } - - &.is-draft { // TODO DEPRECATED: Remove and replace with references with `s-badge s-badge__info s-badge__icon` - background-color: var(--blue-300); - border-color: var(--blue-500); - color: var(--blue-600); - } + align-items: center; + display: flex; + gap: var(--su6); + } - &.is-pinned { // TODO DEPRECATED: Remove and replace with references with `s-badge s-badge__muted s-badge__filled s-badge__icon` - background-color: var(--black-500); - color: var(--white); - } + & &--tags { + display: flex; + flex-wrap: wrap; + gap: var(--su8); + margin-top: var(--su6); + } - &.is-published { - background-color: var(--black-150); - color: var(--black-600); - } + & &--title { + display: flex; + gap: var(--su6); + line-height: 1.25rem; // TODO use a standard line-height variable + } - &.is-review { // TODO DEPRECATED: Remove and replace with references with `s-badge s-badge__warning s-badge__icon` - background-color: var(--yellow-300); - border-color: var(--yellow-500); - color: var(--yellow-600); - } + & &--title-link { + &:hover { + --_ps-title-link-fc: var(--theme-secondary-500); - &__emphasized { - color: var(--_ps-stats-item-emphasized-fc); + &:visited { + color: var(--theme-secondary-600); } } - &--stats-item-number { - font-weight: 500; + // TODO: Since custom property is not being applied for some reason, we're changing the color directly. Remove this once the custom property is fixed. + // I suspect this is because of restrictions on the `:visited` pseudo-class. + // See https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Selectors/:visited#privacy_restrictions + &:visited { + color: var(--theme-secondary-400); } + color: var(--_ps-title-link-fc); + display: flex; + font-size: var(--fs-body3); + font-weight: 600; + gap: var(--su4); + margin-top: var(--su2); + } + + & &--title-icon { + flex-shrink: 0; } - background-color: var(--_ps-bg); - border-bottom: var(--_ps-bb); + // Container query setup + container-type: inline-size; + container-name: post-summary; + color: var(--black-500); display: flex; - padding: var(--su16); + gap: var(--su16); position: relative; + width: 100%; } diff --git a/packages/stacks-classic/lib/components/post-summary/post-summary.test.setup.ts b/packages/stacks-classic/lib/components/post-summary/post-summary.test.setup.ts index 83e96f5288..7bd2277b2f 100644 --- a/packages/stacks-classic/lib/components/post-summary/post-summary.test.setup.ts +++ b/packages/stacks-classic/lib/components/post-summary/post-summary.test.setup.ts @@ -1,435 +1,335 @@ import { html } from "@open-wc/testing"; import { IconArchiveSm, - IconCheckmarkSm, - IconEllipsisVertical, - IconEyeSm, - IconNotInterested, - IconPencilSm, - IconTackSm, - IconTrashSm, -} from "@stackoverflow/stacks-icons-legacy/icons"; + IconDocumentAlt, + IconShield, +} from "@stackoverflow/stacks-icons-legacy"; import type { TestVariationArgs } from "../../test/test-utils"; import "../../index"; +import { + IconAnswer16, + IconAnswer16Fill, + IconCompose, +} from "@stackoverflow/stacks-icons/icons"; -type BadgeType = - | "danger" - | "danger-filled" - | "info" - | "muted" - | "muted-filled" - | "warning"; +// ISO timestamp used for the user time tooltip (title) +const USER_TIME_ISO = "2026-01-15T12:00:00.000Z"; type Stats = { - votes: number; - views: number; - answers: number; + votes: number | string; + views: number | string; + answers: number | string; accepted?: boolean; - bounty?: number; - badge?: BadgeType; + bounty?: number | string; + badge?: boolean; }; -type Tags = { text: string; type?: "required" | "moderator" }[]; - -type TruncationSizes = "sm" | "md" | "lg" | ""; +type Tags = { + text: string; + type?: "ignored" | "watched"; +}[]; -const formatNumber = (num: number) => { - switch (true) { - case num > 10000090: - return (num / 1000000).toFixed(0) + "m"; - case num > 1000000: - return (num / 1000000).toFixed(1) + "m"; - case num > 10000: - return (num / 1000).toFixed(0) + "k"; - case num > 1000: - return (num / 1000).toFixed(1) + "k"; - default: - return num.toString(); - } +const getBadge = () => { + return `${IconCompose} Draft`; }; -const getBadge = (type: BadgeType) => { - const badgeClasses = type - .split("-") - .map((modifier) => `s-badge__${modifier}`) - .join(" "); - const getIcon = () => { - switch (type) { - case "danger": - return IconNotInterested; - case "danger-filled": - return IconTrashSm; - case "info": - return IconPencilSm; - case "muted": - return IconArchiveSm; - case "muted-filled": - return IconTackSm; - case "warning": - return IconEyeSm; - default: - return ""; +// Stats block for wide containers +const getStatsSmHide = ({ votes, answers, accepted, bounty }: Stats) => ` +
+
+ ${Number(votes) > 0 ? "+" : ""}${votes} + ${votes === 1 ? "vote" : "votes"} +
+
+ ${accepted ? IconAnswer16Fill : IconAnswer16} + ${answers} + ${answers === 1 ? "answer" : "answers"} +
+ ${ + bounty + ? ` +
+ + + ${bounty} + bounty +
+ ` + : "" } - }; +
+`; - const getText = () => { - switch (type) { - case "danger": - return "Closed"; - case "danger-filled": - return "Deleted"; - case "info": - return "Draft"; - case "muted": - return "Archived"; - case "muted-filled": - return "Pinned"; - case "warning": - return "Review"; - default: - return ""; +// Stats block for small containers +const getStatsSmShow = ({ votes, answers, accepted, bounty }: Stats) => ` +
+
+ ${votes} + votes +
+
+ ${accepted ? IconAnswer16Fill : IconAnswer16} + ${answers} + ${answers === 1 ? "answer" : "answers"} +
+ ${ + bounty + ? ` +
+ + + ${bounty} + bounty +
+ ` + : "" } - }; - - return ` -
- ${getIcon()} - ${getText()} -
`; -}; - -const getDescription = (truncation?: TruncationSizes, text?: string) => ` -

- ${text ? text : "In the spirit of type safety associated with the CriteriaQuery JPA 2.0 also has an API to support Metamodel representation of entities. Is anyone aware of a fully functional implementation of this API (to generate the Metamodel as opposed to creating the metamodel classes manually)? It would be awesome if someone also knows the steps for setting this up in Eclipse (I assume it's as simple as setting up an annotation processor, but you never know)."} -

+
`; -const getHotnessClass = (num: number) => { - switch (true) { - case num > 100000: - return "is-supernova"; - case num > 10000: - return "is-hot"; - case num > 1000: - return "is-warm"; - default: - return ""; - } -}; +// Tags +const DEFAULT_TAGS: Tags = [ + { text: "llm" }, + { text: "database" }, + { text: "open-source" }, +]; -const getStats = ({ - votes, - views, - answers, - accepted, - bounty, - badge, -}: Stats) => ` -
- ${badge ? getBadge(badge) : ""} -
- ${formatNumber(votes)} - ${votes === 1 ? "vote" : "votes"} -
-
0 ? "has-answers" : ""}"> - ${accepted ? IconCheckmarkSm : ""} - ${formatNumber(answers)} - ${answers === 1 ? "answer" : "answers"} -
-
- ${formatNumber(views)} - ${views === 1 ? "view" : "views"} -
- ${ - bounty - ? ` -
- +${bounty} -
- ` - : "" - } -
-`; +const getTags = (tags?: Tags, contentType?: string) => { + const tagsArr = tags ?? DEFAULT_TAGS; -const getTags = (tags?: Tags) => { - const tagsArr = tags ?? [ - { - text: "feature-request", - type: "required", - }, - { - text: "status-complete", - type: "moderator", - }, - { - text: "design", - }, - ]; + const contentTypeLink = contentType + ? `${IconDocumentAlt} ${contentType}` + : ""; const tagsHTML = tagsArr - .map( - ({ text, type }) => ` - ${text} - ` - ) + .map(({ text, type }) => { + const sr = + type === "ignored" + ? '
Ignored tag
' + : type === "watched" + ? '
Watched tag
' + : ""; + return `${sr}${text}`; + }) .join(""); - return `
${tagsHTML}
`; + return `
${contentTypeLink}${tagsHTML}
`; }; -const getUser = () => ` -
- - placeholder avatar - Tracy Smith - - Tracy Smith - - +// Answers +const getAnswerBlock = ({ + accepted, + excerpt, +}: { + accepted: boolean; + excerpt: string; +}) => ` +
+ +

${excerpt}

+
+`; + +const DEFAULT_ANSWER_EXCERPT = + "I have built a Retrieval-Augmented Generation (RAG) system using LangChain, a vector database, and an open-source LLM. While it works reasonably well, the model often hallucinates answers or cites sources that are only tangentially related to the user's query."; + +const getAnswersBlock = () => ` +
+ ${getAnswerBlock({ + accepted: true, + excerpt: DEFAULT_ANSWER_EXCERPT, + })} + ${getAnswerBlock({ + accepted: false, + excerpt: DEFAULT_ANSWER_EXCERPT, + })}
`; +// User card +const getUser = () => ` +
+ + + + + Tracy Smith + + + + + +
+`; + const getChildren = ({ show = { - description: false, - menuBtn: false, stats: false, tags: false, title: false, user: false, }, - description = { - truncation: "", - text: "", - }, - stats, + stats: statsParam, tags, title, + badge, + contentType, + gated = false, + answers: includeAnswers = false, }: { show?: { - description?: boolean; - menuBtn?: boolean; stats?: boolean; tags?: boolean; title?: boolean; user?: boolean; }; - description?: { - truncation?: TruncationSizes; - text?: string; - }; stats?: Stats; tags?: Tags; title?: string; -}) => { - const titleEl = - show.title || title - ? ` -

- ${title ? title : "How to generate the JPA entity Metamodel?"} -

- ` - : ""; - const descriptionEl = - show.description || description.truncation || description.text - ? description - ? getDescription(description.truncation, description.text) - : getDescription() - : ""; - const tagsEl = show.tags || tags ? getTags(tags) : ""; - const userEl = show.user ? getUser() : ""; - const menuBtnEl = show.menuBtn - ? ` - - ${IconEllipsisVertical} - menu - - ` - : ""; + badge?: boolean; + contentType?: string; + gated?: boolean; + answers?: boolean; +} = {}) => { + const stats: Stats = statsParam ?? { + votes: 95, + views: "104,123", + answers: 5, + accepted: true, + }; + const tagsEl = getTags(tags, contentType); + const userEl = getUser(); + const answersEl = includeAnswers ? getAnswersBlock() : ""; - return ` - ${ - show.stats || stats - ? getStats( - stats - ? stats - : { - votes: 95, - views: 104123, - answers: 5, - accepted: true, - bounty: 50, - } - ) - : "" - } - ${ - titleEl || descriptionEl || tagsEl || userEl || menuBtnEl - ? ` -
- ${titleEl} - ${descriptionEl} -
- ${tagsEl} - ${userEl} -
- ${menuBtnEl} -
` - : "" - } - `; -}; + const titleEl = ` +
+ ${gated ? `${IconShield}gated` : ""} + Network graph of popular tags on Stack Overflow +
+ `; -const getBadgeChildren = (badge: BadgeType) => { - return getChildren({ - show: { - stats: true, - }, - stats: { - badge: badge, - answers: 0, - votes: 1, - views: 20, - }, - }); -}; + const descriptionEl = ` +

+ I wanted to see how different tags related to each other. The below graph depicts associations between popular tags on our site. Description of analysis: I started looking at the 1000 most popular tags on questions in 2021. I created a list of tags cross joined by the question ID (so if a question contains tags for both Python and Numpy, it would show up in my list). I scaled up by the number of answers each question received (noting that some tag combos had 0 answers) -and then counted each distinct combination of each tag. Due to limitations in graphing, I only displayed the top ~2500 tag combinations - which accounts for tags combos that had more than 40 answers over the entire year. +

+ `; + + const contentMetaEl = ` +
+ ${userEl} + ${statsParam ? getStatsSmShow(stats) : ""} +
${stats.views} ${stats.views === 1 ? "view" : "views"}
+ ${badge ? `
${getBadge()}
` : ""} +
+ `; -const getSizeChildren = (size: string) => { - return getChildren({ - show: { - description: true, - menuBtn: true, - stats: true, - tags: true, - title: true, - user: true, - }, - description: { - truncation: size as TruncationSizes, - }, - }); + return ` + ${statsParam ? getStatsSmHide(stats) : ""} +
+ ${badge ? `
${getBadge()}
` : ""} + ${contentMetaEl} + ${titleEl} + ${descriptionEl} + ${tagsEl} + ${answersEl} +
+ `; }; -const getStatsChildren = ({ - accepted = false, - answers = 1, - views = 20, -}: { - accepted?: boolean; - answers?: number; - views?: number; -}) => - getChildren({ - show: { - stats: true, - }, - stats: { - votes: 1, - answers, - accepted, - views, - }, - }); +// Full base +const fullBaseOptions = { + show: { + stats: true, + tags: true, + title: true, + user: true, + }, + stats: { + votes: 95, + views: "104,123", + answers: 5, + accepted: true, + }, +} as const; // eslint-disable-next-line @typescript-eslint/no-explicit-any const template = ({ component, testid }: any) => html` -
+
${component}
`; -const testArgs: { - [key: string]: TestVariationArgs; -} = { - base: { - baseClass: "s-post-summary", - modifiers: { - primary: ["deleted", "ignored", "watched"], // variants described as modifiers to test colliding modifiers - secondary: ["minimal"], - global: ["w100"], - }, - children: { - default: getChildren({ - show: { - description: true, - menuBtn: true, - stats: true, - tags: true, - title: true, - user: true, - }, - }), - sparce: getChildren({ - show: { - stats: true, - tags: true, - title: true, - user: true, - }, - tags: [ - { - text: "featured-request", - type: "required", - }, - ], - title: "Short title", - }), - }, - options: { - includeNullModifier: false, - }, - template, - }, - badges: { - baseClass: "s-post-summary", - children: { - "badge-danger": getBadgeChildren("danger"), - "badge-danger-filled": getBadgeChildren("danger-filled"), - "badge-info": getBadgeChildren("info"), - "badge-muted": getBadgeChildren("muted"), - "badge-muted-filled": getBadgeChildren("muted-filled"), - "badge-warning": getBadgeChildren("warning"), - }, - template, +const testArgs: TestVariationArgs = { + baseClass: "s-post-summary", + modifiers: { + primary: ["answered", "deleted"], + global: [""], }, - sizes: { - baseClass: "s-post-summary", - modifiers: { - global: ["w100"], - }, - children: { - "description-sm": getSizeChildren("sm"), - "description-md": getSizeChildren("md"), - "description-lg": getSizeChildren("lg"), - }, - options: { - includeNullModifier: false, - }, - template, + children: { + "default": getChildren(fullBaseOptions), + "bounty": getChildren({ + ...fullBaseOptions, + stats: { ...fullBaseOptions.stats, bounty: 50 }, + }), + "ignored-tag": getChildren({ + ...fullBaseOptions, + tags: [...DEFAULT_TAGS, { text: "ai", type: "ignored" }], + }), + "watched-tag": getChildren({ + ...fullBaseOptions, + tags: [...DEFAULT_TAGS, { text: "ai", type: "watched" }], + }), + "draft-badge": getChildren({ + ...fullBaseOptions, + badge: true, + }), + "announcement-content-type": getChildren({ + ...fullBaseOptions, + contentType: "Announcement", + }), + "small-container": getChildren(fullBaseOptions), + "with-answers": getChildren({ + ...fullBaseOptions, + answers: true, + }), + "gated": getChildren({ + ...fullBaseOptions, + gated: true, + }), }, - stats: { - baseClass: "s-post-summary", - children: { - "stats-unanswered": getStatsChildren({ answers: 0 }), - "stats-answered": getStatsChildren({ answers: 1 }), - "stats-answered-accepted": getStatsChildren({ - answers: 10, - accepted: true, - }), - "stats-views": getStatsChildren({ views: 1 }), - "stats-views-warm": getStatsChildren({ views: 1001 }), - "stats-views-hot": getStatsChildren({ views: 10001 }), - "stats-views-supernova": getStatsChildren({ views: 100001 }), - }, - template, + options: { + includeNullModifier: false, }, + template, }; export default testArgs; diff --git a/packages/stacks-classic/lib/components/post-summary/post-summary.visual.test.ts b/packages/stacks-classic/lib/components/post-summary/post-summary.visual.test.ts index 94e953d9d2..1aa6c0eeae 100644 --- a/packages/stacks-classic/lib/components/post-summary/post-summary.visual.test.ts +++ b/packages/stacks-classic/lib/components/post-summary/post-summary.visual.test.ts @@ -3,15 +3,5 @@ import testArgs from "./post-summary.test.setup"; import "../../index"; describe("post-summary", () => { - // Base, sparce - runVisualTests(testArgs.base); - - // Truncated description sizes - runVisualTests(testArgs.sizes); - - // Stats - answers, view hotness - runVisualTests(testArgs.stats); - - // Badges - runVisualTests(testArgs.badges); + runVisualTests(testArgs); }); diff --git a/packages/stacks-docs/_data/post-summary.json b/packages/stacks-docs/_data/post-summary.json index 80f0c84491..eb7d0604f4 100644 --- a/packages/stacks-docs/_data/post-summary.json +++ b/packages/stacks-docs/_data/post-summary.json @@ -6,109 +6,230 @@ "description": "Base parent container for a post summary" }, { - "class": ".s-post-summary--stats", + "class": ".s-post-summary__answered", "applies": ".s-post-summary", - "description": "Container for the post summary stats" + "description": "Adds the styling necessary for a question with accepted answers" }, { - "class": ".s-post-summary--stats-item", - "applies": ".s-post-summary--stats", - "description": "Individual stat item within the stats container" + "class": ".s-post-summary__deleted", + "applies": ".s-post-summary", + "description": "Adds the styling necessary for a deleted post" }, { - "class": ".s-post-summary--stats-item-number", - "applies": ".s-post-summary--stats-item", - "description": "Container for applying styling to the number of a stat item" + "class": ".s-post-summary--sm-hide", + "applies": ".s-post-summary", + "description": "Hides the stats container on small screens" }, { - "class": ".s-post-summary--stats-item-unit", - "applies": ".s-post-summary--stats-item", - "description": "Container for applying styling to the unit of a stat item" + "class": ".s-post-summary--sm-show", + "applies": ".s-post-summary", + "description": "Shows the stats container on small screens" }, { - "class": ".has-answers", - "applies": ".s-post-summary--stats-item", - "description": "Adds the styling necessary for a question with answers" + "class": ".s-post-summary--answers", + "applies": ".s-post-summary", + "description": "Container for the post summary answers" }, { - "class": ".has-accepted-answer", - "applies": ".s-post-summary--stats-item", - "description": "Adds the styling necessary for a question with accepted answers" + "class": ".s-post-summary--answer", + "applies": ".s-post-summary--answers", + "description": "Container for the post summary answer" }, { - "class": ".has-bounty", - "applies": ".s-post-summary--stats-item", - "description": "Styles the stats item appropriately to display a bounty" + "class": ".s-post-summary--answer__accepted", + "applies": ".s-post-summary--answer", + "description": "Adds the styling necessary for an accepted answer" }, { - "class": ".is-warm", - "applies": ".s-post-summary--stats-item", - "description": "Styles post stats with a warm color" + "class": ".s-post-summary--content", + "applies": ".s-post-summary", + "description": "Container for the post summary content" }, { - "class": ".is-hot", - "applies": ".s-post-summary--stats-item", - "description": "Warmer still, for more popular posts" + "class": ".s-post-summary--content-meta", + "applies": ".s-post-summary--content", + "description": "A container for post meta data, things like tags and user cards." }, { - "class": ".is-supernova", - "applies": ".s-post-summary--stats-item", - "description": "Paired with a fire icon, these are the most popular stats" + "class": ".s-post-summary--content-type", + "applies": ".s-post-summary--content", + "description": "Container for the post summary content type" }, { - "class": ".s-post-summary--content", + "class": ".s-post-summary--excerpt", + "applies": ".s-post-summary--content", + "description": "Container for the post summary excerpt" + }, + { + "class": ".s-post-summary--stats", "applies": ".s-post-summary", - "description": "Container for the post summary content" + "description": "Container for the post summary stats" }, { - "class": ".s-post-summary--content-type", - "applies": ".s-post-summary--content", - "description": "A container for various content types, eg. How-to guide on Articles" + "class": ".s-post-summary--stats-answers", + "applies": ".s-post-summary--stats", + "description": "Container for the post summary answers" }, { - "class": ".s-post-summary--content-title", - "applies": ".s-post-summary--content", - "description": "Post title styling" + "class": ".s-post-summary--stats-bounty", + "applies": ".s-post-summary--stats", + "description": "Container for the post summary bounty" }, { - "class": ".s-post-summary--content-excerpt", - "applies": ".s-post-summary--content", - "description": "An optional content excerpt truncated at 2 lines." + "class": ".s-post-summary--stats-item", + "applies": ".s-post-summary--stats", + "description": "A genericcontainer for views, comments, read time, and other meta data which prepends a separator icon." }, { - "class": ".s-post-summary--content-excerpt__sm", - "applies": ".s-post-summary--content-excerpt", - "description": "An optional content excerpt truncated at 1 line." + "class": ".s-post-summary--stats-votes", + "applies": ".s-post-summary--stats", + "description": "Container for the post summary votes" }, { - "class": ".s-post-summary--content-excerpt__md", - "applies": ".s-post-summary--content-excerpt", - "description": "An optional content excerpt truncated at 3 lines." + "class": ".s-post-summary--tags", + "applies": ".s-post-summary", + "description": "Container for the post summary tags" }, { - "class": ".s-post-summary--answer", - "applies": ".s-post-summary--content", - "description": "Adds blockquote styling and spacing for answer previews" + "class": ".s-post-summary--title", + "applies": ".s-post-summary", + "description": "Container for the post summary title" }, { - "class": ".s-post-summary--answer-excerpt", - "applies": ".s-post-summary--content", - "description": "Provides padding, and truncation to 4 lines." + "class": ".s-post-summary--title-link", + "applies": ".s-post-summary--title", + "description": "Link styling for the post summary title" }, { - "class": ".s-post-summary--content-menu-button", - "applies": ".s-post-summary--content", - "description": "An optional button for displaying a post-specific menu." + "class": ".s-post-summary--title-icon", + "applies": ".s-post-summary--title", + "description": "Icon styling for the post summary title" + } + ], + "answered": { + "answered": true + }, + "bountied": { + "bounty": 50 + }, + "small": [ + { + "excerptLines": 2 }, { - "class": ".s-post-summary--meta", - "applies": ".s-post-summary--content", - "description": "A container for post meta data, things like tags and user cards." + "answered": true, + "excerptLines": 2 }, { - "class": ".s-post-summary--meta-tags", - "applies": ".s-post-summary--meta", - "description": "A container for tags and other taxonomy." + "bounty": 50, + "excerptLines": 2 + } + ], + "ignored": { + "tags": [ + { + "text": "retrieval-augmented-generation", + "ignored": true + }, + "langchain", + "llm", + "vector-database", + "ai" + ], + "answered": true, + "bounty": 50 + }, + "watched": { + "tags": [ + { + "text": "retrieval-augmented-generation", + "watched": true + }, + "langchain", + "llm", + "vector-database", + "ai" + ], + "answered": true + }, + "deleted": { + "answered": true, + "modifier": "s-post-summary__deleted" + }, + "states": [ + { + "badge": { + "class": "s-badge__info", + "text": "Draft", + "icon": "Compose" + } + }, + { + "badge": { + "class": "s-badge__warning", + "text": "Review", + "icon": "Eye" + } + }, + { + "badge": { + "class": "s-badge__danger", + "text": "Closed", + "icon": "Flag" + } + }, + { + "badge": { + "text": "Archived", + "icon": "Document" + } + }, + { + "badge": { + "class": "s-badge__tonal", + "text": "Pinned", + "icon": "Key" + } + } + ], + "contentTypes": [ + { + "type": "Announcement" + }, + { + "type": "How to guide" + }, + { + "type": "Knowledge article" + }, + { + "type": "Policy" + } + ], + "excerptSizes": [ + { + "excerptLines": 0 + }, + { + "excerptLines": 1 + }, + { + "excerptLines": 2 + }, + { + "excerptLines": 3 + } + ], + "withAnswers": [ + { + "answered": true, + "answerCount": 2, + "answers": [ + { + "accepted": true + }, + {} + ] } ] -} \ No newline at end of file +} diff --git a/packages/stacks-docs/_data/site-navigation.json b/packages/stacks-docs/_data/site-navigation.json index e439354bb4..a02c9a52e8 100644 --- a/packages/stacks-docs/_data/site-navigation.json +++ b/packages/stacks-docs/_data/site-navigation.json @@ -328,7 +328,8 @@ }, { "title": "Post summary", - "url": "/product/components/post-summary/" + "url": "/product/components/post-summary/", + "new": true }, { "title": "Progress bars", diff --git a/packages/stacks-docs/_includes/post-summary-item.html b/packages/stacks-docs/_includes/post-summary-item.html new file mode 100644 index 0000000000..9a4b0ea67b --- /dev/null +++ b/packages/stacks-docs/_includes/post-summary-item.html @@ -0,0 +1,199 @@ +{% comment %} + This partial renders a post summary item. + + Parameters: + - data (required): The post summary data object. + - answered (optional): Whether the post is answered. + - bounty (optional): The bounty amount. + - modifier (optional): The modifier to apply to the post summary. + - tags (optional): The tags to display. + - user (optional): The user to display. + - avatar (optional): The avatar image source. + - href (optional): The href of the user. + - reputation (optional): The reputation of the user. + - time (optional): The time of the user. + - tooltip (optional): The tooltip of the user. + - username (optional): The username of the user. + - votes (optional): The number of votes. + - answerCount (optional): The number of answers. + - views (optional): The number of views. + - excerpt (optional): The excerpt of the post. + - excerptLength (optional): The length of the excerpt. +{% endcomment %} + +{% assign defaultExcerpt = "I have built a Retrieval-Augmented Generation (RAG) system using LangChain, a vector database, and an open-source LLM. While it works reasonably well, the model often hallucinates answers or cites sources that are only tangentially related to the user's query. My chunking strategy is set to a chunk size of 1000 tokens, which seems to be the sweet spot for the model." %} +{% assign defaultTags = "retrieval-augmented-generation, langchain, llm, vector-database" | split: ", " %} +
+
+
+ +{{ data.votes | default: 24 }} + votes +
+
+ {% if data.answered %} + {% icon "Answer16Fill" %} + {% else %} + {% icon "Answer16" %} + {% endif %} + {{ data.answerCount | default: 1 }} + answers +
+ {% if data.bounty %} +
+ + + {{ data.bounty }} +
+ {% endif %} +
+
+
+ {% if data.badge %} + + {% if data.badge.icon %} + {% icon data.badge.icon %} + {% endif %} + {{ data.badge.text }} + + {% endif %} +
+ +
+ {% if data.gated %} + {% icon "Shield16", "s-post-summary--title-icon" %} + {% endif %} + + + {{ data.title | default: "How to reduce hallucinations and improve source relevance in a RAG pipeline?" }} + +
+ {% if data.excerptLines != 0 %} +

+ {{ data.excerpt | default: defaultExcerpt }} +

+ {% endif %} +
+ {% if data.type %} + + {% icon "Document" %} + {{ data.type }} + + {% endif %} + {% if data.tags.size > 0 %} + {% for tag in data.tags %} + + {% if tag.ignored %}
Ignored tag
{% endif %} + {% if tag.watched %}
Watched tag
{% endif %} + {{ tag.text | default: tag }} +
+ {% endfor %} + {% else %} + {% for tag in defaultTags %} + {{ tag }} + {% endfor %} + {% endif %} +
+ {% if data.answerCount > 0 %} +
+ {% for answer in data.answers %} +
+ +
+ {{ answer.excerpt | default: defaultExcerpt }} +
+
+ {% endfor %} +
+ {% endif %} +
+
diff --git a/packages/stacks-docs/product/components/post-summary.html b/packages/stacks-docs/product/components/post-summary.html index 89da614064..912baed581 100644 --- a/packages/stacks-docs/product/components/post-summary.html +++ b/packages/stacks-docs/product/components/post-summary.html @@ -5,6 +5,7 @@ description: The post summary component summarizes various content and associated meta data into a highly configurable component. tags: components --- +
{% header "h2", "Classes" %}
@@ -28,1917 +29,559 @@
-
- {% header "h2", "Layout Examples" %} -

Post summaries are a flexible component capable of previewing various content types—questions or articles. Post summaries are meant to be flexible, allowing for each individual piece to be removed. Let’s start with a full example of a few questions and an article interleaved.

- {% header "h3", "Full" %} -

At their most complete, post summaries are presented in two columns and can contain:

-
    -
  • Title
  • -
  • Excerpt
  • -
  • Tags & other categorization
  • -
  • Author & creation date
  • -
  • Stats & various states
  • -
  • Contextual menu
  • -
-

At the smallest breakpoint, the layout will switch to a single column.

+{% header "h2", "Examples" %} +{% header "h3", "Base" %} +
+

Use the post summary component to provide a concise summary of a question, article, or other content.

{% highlight html %}
-
-
- - … - - - votes - -
-
- @Svg.CheckmarkSm - - … - - - answers - -
-
- - … - - - views - -
+
+
+
-
- - @Svg… … - -
-

- -

-

-
-
- -
- -
- - - - - -
    -
  • -
- -
-
- - @Svg.EllipsisVertical - - -
-
-{% endhighlight %} -
-
-
-
- - 95 - - - votes - -
-
- {% icon "CheckmarkSm" %} - - 5 - - - answers - -
-
- - 104k - - - views - -
-
-
-

- How to generate the JPA entity Metamodel? -

-

- In the spirit of type safety associated with the CriteriaQuery JPA 2.0 also has an API to support Metamodel representation of entities. -

-
-
- java - hibernate - jpa - annotation-processing - metamodel -
- -
- - placeholder avatar - Paul Wright - - Paul Wright -
    -
  • 1350
  • -
- -
-
- - {% icon "EllipsisVertical" %} - menu - -
-
- -
-
-
- - 280 - - - votes - -
-
- - 4 - - - answers - -
-
- - 10.2k - - - views - -
-
- +50 -
-
-
-

- Cannot read property 'startsWith' of null in npm install -

-

- I am creating my first react-native app. I am attempting to install the react-native command line interface as shown here. I keep getting an error when I type the command to initiate the react-native command line -

-
-
- node-js - angular - npm - javascript -
- -
- - placeholder avatar - Aaron Shekey - - Aaron Shekey -
    -
  • 205
  • -
- -
-
- - {% icon "EllipsisVertical" %} - menu - -
-
- -
-
-
- - 1 - - - vote - -
-
- - 15 - - - views - -
-
- - 1 - - - minute read - -
-
- - 1 - - - comment - -
-
-
-
- {% icon "DocumentAlt" %} How-to guide -
-

- How to run a product research study -

-

- This article aims to guide you through the steps and processes you may want to consider when running research at Stack Overflow. While there are obviously many methods, this guide will focus more interview-style research. For a full list of Product Research templates, guides, and links, see this article. -

-
-
- ux-research -
- -
- - placeholder avatar - Mithila Fox - - Mithila Fox -
    -
  • 323
  • -
- -
-
- - {% icon "EllipsisVertical" %} - menu +
- {% header "h3", "Minimal" %} -

There are compact views where it’s appropriate to reduce the amount of information within the post summary. The minimal view forces the smallest breakpoint layout in any context, stacking the meta data on top and putting everything into a single column.

+{% header "h3", "Answered" %} +
+

+ Add the .s-post-summary__answered modifier class to indicate that the post has an accepted answer. +

{% highlight html %} -
-
-
- - … - - - votes - -
-
- @Svg.CheckmarkSm - - … - - - answers - -
-
- - … - - - views - +
+ … +
+{% endhighlight %} +
+ {% render 'post-summary-item.html', data: post-summary.answered %} +
+
+ +
+ {% header "h3", "Bountied" %} +

+ Include the .s-post-summary--stats-bounty element to indicate that the post has a bounty. +

+
+{% highlight html %} +
+
+
+
+
+ +50 bounty
-

- -

-
-
- -
- -
- - - - - - + … + + …
+ …
{% endhighlight %} -
-
-
-
- - 0 - - - votes - -
-
- - 0 - - - answers - -
-
- - 2 - - - views - -
-
-
-

- Clicking through a viewpager2? -

-
-
- android - android-studio - android-viewpager2 -
- -
- - placeholder avatar - Callum Osborne - - Callum Osborne - -
-
-
-
-
-
-
- - 0 - - - votes - -
-
- - 1 - - - answer - -
-
- - 3 - - - views - -
-
-
-

- What permissions does github_token require for releases from a github action -

-
-
- github-actions -
- -
- - placeholder avatar - GuiFalourd - - GuiFalourd - -
-
-
-
-
-
-
- - 0 - - - votes - -
-
- - 0 - - - answers - -
-
- - 5 - - - views - -
-
-
-

- Parentheses is Invalid Bash -

-
-
- linux - bash - docker - shell -
- -
- - placeholder avatar - thatkingguy_ - - thatkingguy_ - -
-
-
-
-
+
+ {% render 'post-summary-item.html', data: post-summary.bountied %}
+
- {% header "h3", "Excerpts" %} -

Posts can be shown with or without excerpts. Stacks also provides various size

+{% header "h3", "Ignored" %} +
+

Including an ignored tag will automatically apply custom ignored styling to the post summary.

{% highlight html %} -… -

-

-

-

+
+ … +
+ … +
+ + … +
+
+
{% endhighlight %} -
-
-
-
- - 95 - - - votes - -
-
- {% icon "CheckmarkSm" %} - - 5 - - - answers - -
-
- - 104k - - - views - -
-
-
-

- How to generate the JPA entity Metamodel? -

-
-
- java - hibernate - jpa - annotation-processing - metamodel -
- -
- - placeholder avatar - Paul Wright - - Paul Wright -
    -
  • 1350
  • -
- -
-
- - {% icon "EllipsisVertical" %} - menu - -
-
- -
-
-
- - 95 - - - votes - -
-
- {% icon "CheckmarkSm" %} - - 5 - - - answers - -
-
- - 104k - - - views - -
-
-
-

- How to generate the JPA entity Metamodel? -

-

- In the spirit of type safety associated with the CriteriaQuery JPA 2.0 also has an API to support Metamodel representation of entities. - -Is anyone aware of a fully functional implementation of this API (to generate the Metamodel as opposed to creating the metamodel classes manually)? It would be awesome if someone also knows the steps for setting this up in Eclipse (I assume it's as simple as setting up an annotation processor, but you never know). -

-
-
- java - hibernate - jpa - annotation-processing - metamodel -
- -
- - placeholder avatar - Paul Wright - - Paul Wright -
    -
  • 1350
  • -
- -
-
- - {% icon "EllipsisVertical" %} - menu - -
-
- -
-
-
- - 95 - - - votes - -
-
- {% icon "CheckmarkSm" %} - - 5 - - - answers - -
-
- - 104k - - - views - -
-
-
-

- How to generate the JPA entity Metamodel? -

-

- In the spirit of type safety associated with the CriteriaQuery JPA 2.0 also has an API to support Metamodel representation of entities. Is anyone aware of a fully functional implementation of this API (to generate the Metamodel as opposed to creating the metamodel classes manually)? It would be awesome if someone also knows the steps for setting this up in Eclipse (I assume it's as simple as setting up an annotation processor, but you never know). -

-
-
- java - hibernate - jpa - annotation-processing - metamodel -
- -
- - placeholder avatar - Paul Wright - - Paul Wright -
    -
  • 1350
  • -
- -
-
- - {% icon "EllipsisVertical" %} - menu - -
-
- -
-
-
- - 95 - - - votes - -
-
- {% icon "CheckmarkSm" %} - - 5 - - - answers - -
-
- - 104k - - - views - -
-
-
-

- How to generate the JPA entity Metamodel? -

-

- In the spirit of type safety associated with the CriteriaQuery JPA 2.0 also has an API to support Metamodel representation of entities. Is anyone aware of a fully functional implementation of this API (to generate the Metamodel as opposed to creating the metamodel classes manually)? It would be awesome if someone also knows the steps for setting this up in Eclipse (I assume it's as simple as setting up an annotation processor, but you never know). -

-
-
- java - hibernate - jpa - annotation-processing - metamodel -
- -
- - placeholder avatar - Paul Wright - - Paul Wright -
    -
  • 1350
  • -
- -
-
- - {% icon "EllipsisVertical" %} - menu - -
-
- -
-
-
- - 95 - - - votes - -
-
- {% icon "CheckmarkSm" %} - - 5 - - - answers - -
-
- - 104k - - - views - -
-
-
-

- How to generate the JPA entity Metamodel? -

-

- In the spirit of type safety associated with the CriteriaQuery JPA 2.0 also has an API to support Metamodel representation of entities. Is anyone aware of a fully functional implementation of this API (to generate the Metamodel as opposed to creating the metamodel classes manually)? It would be awesome if someone also knows the steps for setting this up in Eclipse (I assume it's as simple as setting up an annotation processor, but you never know). -

-
-
- java - hibernate - jpa - annotation-processing - metamodel -
- -
- - placeholder avatar - Paul Wright - - Paul Wright -
    -
  • 1350
  • -
- -
-
- - {% icon "EllipsisVertical" %} - menu - -
-
+
+ {% render 'post-summary-item.html', + data: post-summary.ignored + %}
+{% header "h3", "Watched" %}
- {% header "h2", "Answers" %} -

Previews of answers can also be attached to the post summary as needed.

+

Including a watched tag will automatically apply custom watched styling to the post summary.

{% highlight html %}
-

- -

-
-
-
-
- - … - - - votes - -
-
- @Svg.CheckmarkSm Accepted answer -
-
-

- … -

-
- - View answer - - -
- - - - - - -
-
+ … +
+ + …
{% endhighlight %} -
-
-
-
- - 2 - - - votes - -
-
- {% icon "CheckmarkSm" %} - - 2 - - - answers - -
-
- - 1k - - - views - -
-
-
-

- Azure API Management and Backend Web API -

-

- Right now, I have enabled basic authentication to the developer portal in API Management. Also, I have enabled OAuth 2.0 authentication for the back end server (user Authorization). So, if i login to the developer portal, i can see two fields - Subscription Key and Authorization. The Subscription key will be the developer's subscription to the portal and the Authorization will be the OAuth authorization which is required for the back end server. -

-
-
- azure - asp.net-web-api - oauth-2.0 - azure-active-directory -
- -
- - placeholder avatar - karel - - karel - -
-
-
-
-
- - 2 - - - votes - -
-
- {% icon "CheckmarkSm" %} Accepted answer -
-
-

Subscription keys in APIM are tied to a user and product, thus if you change (or create new one) product to not require subscription (option available at creation time or in product settings) no subscription key would be needed to call any API included into such products. The downside is that all such calls would be treated by APIM as anonymous and shown in analytics as such.

-
- - View answer - - -
- - placeholder avatar - Aaron Shekey - - Aaron Shekey - -
-
-
-
-
-
- - 2 - - - votes - -
-
-

Yeah, you can do that (a bit of a hack). You have to use REST Api for that, specifically this call. For me it didn't work to edit the existing API (they key was still there), but when I've created new API, key wasn't there:

-
- - View answer - - -
- - placeholder avatar - 4c74356b41 - - 4c74356b41 - -
-
-
-
-
+
+ {% render 'post-summary-item.html', + data: post-summary.watched + %}
+{% header "h3", "Deleted" %}
- {% header "h2", "Stats examples" %} - {% header "h3", "Answered status" %} -

Within the stats section of the post summary, posts can have various answered states. A checkmark icon is displayed in questions that have accepted answers.

-
    -
  • No answers
  • -
  • Has answers
  • -
  • Has accepted answers
  • -
+

Include the .s-post-summary__deleted modifier class applies custom deleted styling to the post summary.

{% highlight html %} - -
- - 2 - - - answers - -
- - -
- - 2 - - - answers - -
- - -
- @Svg.CheckmarkSm - - 5 - - - answers - +
+ …
{% endhighlight %}
-
-
-
- - 3 - - - votes - -
-
- - 0 - - - answers - -
-
- - 32 - - - views - -
-
- -
-
- - 12 - - - votes - -
-
- - 2 - - - answers - -
-
- - 145 - - - views - -
-
- -
-
- - 95 - - - votes - -
-
- {% icon "CheckmarkSm" %} - - 5 - - - answers - -
-
- - 380 - - - votes - -
-
-
+ {% render 'post-summary-item.html', + data: post-summary.deleted + %}
+
- {% header "h3", "Hotness" %} -

Post stats also have various states of hotness and display in corresponding shades of orange.

-
    -
  • Default
  • -
  • Warm
  • -
  • Hot
  • -
  • Supernova
  • -
+{% header "h3", "State badges" %} +
+

Include the appropriate state badge to indicate the current state of the post.

{% highlight html %} - -
- - 23 - - - views - -
- - -
- - 1k - - - views - + +
+
+
+
+ + {% icon "Compose" %} Draft + +
+ + … +
- -
- - 10k - - - views - + +
+
+
+
+ + {% icon "Eye" %} Review + +
+ + … +
- -
- - 100k - - - views - + +
+
+
+
+ + {% icon "Flag" %} Closed + +
+ + … +
-{% endhighlight %} -
-
-
-
- - 3 - - - votes - -
-
- - 0 - - - answers - -
-
- - 23 - - - views - -
-
- -
-
- - 28 - - - votes - -
-
- {% icon "CheckmarkSm" %} - - 3 - - - answers - -
-
- - 1k - - - views - -
-
-
-
- - 92 - - - votes - -
-
- {% icon "CheckmarkSm" %} - - 10 - - - answers - -
-
- - 10k - - - views - -
-
+ +
+
+
+
+ + {% icon "Document" %} Archived + +
+ + … +
+
-
-
- - 126 - - - votes - -
-
- {% icon "CheckmarkSm" %} - - 18 - - - answers - -
-
- - 100k - - - views - -
-
-
+ +
+
+
+
+ + {% icon "Key" %} Pinned + +
+ + … +
+
+{% endhighlight %} +
+ {% for data in post-summary.states %} + {% render 'post-summary-item.html', + data: data + %} + {% endfor %}
+{% header "h3", "Content types" %}
- {% header "h2", "States" %} -

Post summaries change their appearance based on being watched, ignored, or deleted. Articles can also have varying draft states.

+

Include the appropriate content type badge to indicate the type of content the post represents.

{% highlight html %} -
-
-
- @Svg.TackSm - Pinned -
+ +
+ … +
… +
+ + {% icon "Document" %} Announcement + … +
- … -
+
-
+ +
… - +
… - - … +
+ + {% icon "Document" %} How to guide + … +
+
-
+ +
… - +
… - - … +
+ + {% icon "Document" %} Knowledge article + … +
+
-
-
-
- @Svg.TrashSm - Deleted -
+ +
+ … +
… +
+ + {% icon "Document" %} Policy + … +
- … -
+
{% endhighlight %} -
-
-
-
- {% icon "TackSm" %} - Pinned -
-
- - 10 - - - votes - -
-
- - 12 - - - views - -
-
- - 1 - - - minute read - -
-
-
-
- {% icon "DocumentAlt" %} Knowledge Article -
-

- Network graph of popular tags on Stack Overflow -

-

- I wanted to see how different tags related to each other. The below graph depicts associations between popular tags on our site. Description of analysis: I started looking at the 1000 most popular tags on questions in 2021. I created a list of tags cross joined by the question ID (so if a question contains tags for both Python and Numpy, it would show up in my list). I scaled up by the number of answers each question received (noting that some tag combos had 0 answers) -and then counted each distinct combination of each tag. Due to limitations in graphing, I only displayed the top ~2500 tag combinations - which accounts for tags combos that had more than 40 answers over the entire year. -

-
-
- data - data-insights -
+
+ {% for data in post-summary.contentTypes %} + {% render 'post-summary-item.html', data: data %} + {% endfor %} +
+
+
-
- - placeholder avatar - Aaron Shekey - - Aaron Shekey -
    -
  • 1,025
  • -
- -
-
- - {% icon "EllipsisVertical" %} - menu - -
- + +{% header "h3", "Excerpt sizes" %} +
+ {% header "h4", "Classes" %} +

Post summaries can be shown without an excerpt or with an excerpt with one, two, or three lines of text. Exclude the excerpt container to hide the excerpt or apply the appropriate truncation class to the excerpt container. See also Truncation.

+
+ + + + + + + + + {% for item in post-summary.excerptSizes %} + {% if item.excerptLines > 0 %} + + + + + {% endif %} + {% endfor %} + +
ClassDescription
.v-truncate{{ item.excerptLines }}Truncates the excerpt to {{ item.excerptLines }} lines of text.
+
-
-
-
- - 10 - - - votes - -
-
- {% icon "CheckmarkSm" %} - - 2 - - - answers - -
-
- - 10k - - - views - -
-
-
-

- Could not load type 'System.Web.Optimization.StyleBundle' -

-

- Sometimes after build and launch my MVC4 web app I got this error. It can dissapear after rebuild or not. Same issue I got after publish to Windows Azure. Does anybody know how to fix this error? -

-
-
- asp - asp.net - asp.net-mvc -
+ {% header "h4", "Examples" %} +
+{% highlight html %} + +
+
+
+
-
- - placeholder avatar - Nimantha - - Nimantha -
    -
  • 5,337
  • -
- -
-
- - {% icon "EllipsisVertical" %} - menu - -
-
+ +
+
+
+ … +

+ … +
+
-
-
-
- - 3 - - - votes - -
-
- - 2 - - - answers - -
-
- - 205 - - - votes - -
-
-
-

- PHP URL Param redirects - with wildcards/regex -

-

- I recently found this solution for doing php url variable to header location redirects. It's so much more manageable compared to htaccess for mass redirects, however one thing I want to next work out templates, guides, and links, see this article. -

-
-
- regex - php - redirect - parameters - wildcard -
+ +
+
+
+ … +

+ … +
+
-
- - placeholder avatar - Jase Wolf - - Jase Wolf -
    -
  • 93
  • -
- -
-
- - {% icon "EllipsisVertical" %} - menu - -
-
+ +
+
+
+ … +

+ … +
+
+{% endhighlight %} +
+ {% for data in post-summary.excerptSizes %} + {% render 'post-summary-item.html', + data: data + %} + {% endfor %} +
+
+
-
-
-
- {% icon "TrashSm" %} - Deleted -
-
- - 0 - - - votes - -
-
- - 0 - - - answers - -
-
- - 5 - - - views - -
-
-
-

- Adding authentication based on API key and API secret to APIs in Spring Boot application -

-

- I am working on a Spring Boot application with user authentication is based on Oauth2 2ith 2FA. Now, I would like to call the APIs in my application from the third-party client as well, say from another service. -

-
-
- spring - spring-boot - authentication - spring-security - oauth-2.0 -
+{% header "h3", "Small container" %} +
+

Post summaries adapt to their container size. When shown with a container smaller than 448px, the post summary renders with a compact layout.

+
+{% highlight html %} +
+{% endhighlight %} +
+
+ {% for data in post-summary.small %} + {% render 'post-summary-item.html', data: data %} + {% endfor %} +
+
+
-
- - placeholder avatar - Joy + +{% header "h3", "Answers" %} +
+ +

Answers to a question can be shown in a post summary. Include the .s-post-summary--answers container to show the answers.

+

For accepted answers, add the .s-post-summary--answer__accepted modifier class and display the Accepted answer text and icon as shown in the example below.

+
+{% highlight html %} +
+
+
+ +
+

+
+
+
+ -
- -
-
-
- {% icon "PencilSm" %} - Draft -
-
- - -3 - - - votes - -
-
- - 541 - - - views - -
-
- - 4 - - - comments - -
-
- - 1 - - - min read - -
-
-
-
- {% icon "DocumentAlt" %} How-to guide -
-

- How to make sql_mode empty on Cloud SQL -

-

- Cloud SQL allows users to customize several flags that allow you to adjust options and configure and tune a database instance. In the case of the sql_mode flag, there are several options that are -

-
-
- google-cloud-sql - sql-mode - sqlmod +
- - -
- - {% icon "EllipsisVertical" %} - menu - -
-
- -
-
-
- {% icon "EyeSm" %} - Review -
-
- - 26 - - - votes - -
-
- - 2k - - - views - -
-
- - 1 - - - comment
-
- - 1 - - - min read - -
-
-
-
- {% icon "DocumentAlt" %} How-to Guide -
-

- How to make sql_mode empty on Cloud SQL -

-

- In Android apps that use Firebase Authentication, you can always get the user who is currently signed in with code like: FirebaseAuth auth = FirebaseAuth.getInstance(); FirebaseUser user = auth.get -

-
-
- firebase-realtime-database - firebase-authentication +
+
+ {% icon "Vote16Up" %} + …
- -
- - placeholder avatar - Frank van Puffelen - - Frank van Puffelen -
    -
  • 464.3k
  • -
- +
+ {% icon "Answer16Fill" %} + Accepted answer
- - {% icon "EllipsisVertical" %} - menu -
+

- -
-
-
- {% icon "NotInterestedSm" %} - Closed -
-
- - 0 - - - votes - -
-
- - 12 - - - views - -
-
-
-
- {% icon "DocumentAlt" %} How-to Guide -
-

- How to make sql_mode empty on Cloud SQL -

-

- In Android apps that use Firebase Authentication, you can always get the user who is currently signed in with code like: FirebaseAuth auth = FirebaseAuth.getInstance(); FirebaseUser user = auth.get -

-
-
- firebase-realtime-database - firebase-authentication -
- -
- - placeholder avatar +
+ -
- -
-
-
- {% icon "ArchiveSm" %} - Archived -
-
- - -22 - - - votes - -
-
- - 1k - - - views - -
-
- - 5 - - - comments - -
-
- - 1 - - - min read +
    +
  • + + reputation bling + + … +
  • +
+ + + +
-
-
-
- {% icon "DocumentAlt" %} Announcement -
-

- Faster Cloud Storage transfers using the gcloud command-line -

-

- We’re pleased to announce gcloud storage, a new set of Cloud Storage commands in Cloud SDK that includes a new approach to parallelization that can accelerate both small and large data migrations. -

-
-
- google-cloud-storage - gcloud - gsutil -
- -
- - placeholder avatar - Frank van Puffelen - - thomasmaclean -
    -
  • 165
  • -
- +
+
+ {% icon "Vote16Up" %} + …
- - {% icon "EllipsisVertical" %} - menu -
+

+
+{% endhighlight %} +
+ {% for data in post-summary.withAnswers %} + {% render 'post-summary-item.html', data: data %} + {% endfor %} +
+
diff --git a/packages/stacks-svelte/src/components/PostSummary/PostSummary.stories.svelte b/packages/stacks-svelte/src/components/PostSummary/PostSummary.stories.svelte index 4c3c8b6519..bc5989b1f9 100644 --- a/packages/stacks-svelte/src/components/PostSummary/PostSummary.stories.svelte +++ b/packages/stacks-svelte/src/components/PostSummary/PostSummary.stories.svelte @@ -4,35 +4,46 @@ import PostSummaryAnswer from "./PostSummaryAnswer.svelte"; import type { ContentTypeName, - ExcerptSize, - Hotness, + ExcerptLines, State, } from "./PostSummary.svelte"; import Answer from "./PostSummaryAnswer.svelte"; import Tag from "../Tag/Tag.svelte"; + import { SvelteDate } from "svelte/reactivity"; + import { formatTime } from "@stackoverflow/stacks-utils"; + + const dateNow = new SvelteDate(); + const dateTwoHoursAgo = new SvelteDate( + dateNow.setHours(dateNow.getHours() - 2) + ); + const readableTimestamp = `asked ${formatTime(dateTwoHoursAgo.toISOString())}`; + const utcTimestamp = dateTwoHoursAgo.toISOString(); const baseArgs = { answers: 1, href: "#", - timestamp: "1 hour ago", + readableTimestamp, + utcTimestamp, userAvatar: "https://avatars.githubusercontent.com/u/1", userName: "John Doe", userProfileUrl: "#john-doe", - userReputation: "1,000", - views: 100, + userReputation: 1000, // TODO SHINE does formatCount need to add commas/periods? + views: 12345, votes: 10, title: "Network graph of popular tags on Stack Overflow", excerpt: "I wanted to see how different tags related to each other. The below graph depicts associations between popular tags on our site. Description of analysis: I started looking at the 1000 most popular tags on questions in 2021. I created a list of tags cross joined by the question ID (so if a question contains tags for both Python and Numpy, it would show up in my list). I scaled up by the number of answers each question received (noting that some tag combos had 0 answers) -and then counted each distinct combination of each tag. Due to limitations in graphing, I only displayed the top ~2500 tag combinations - which accounts for tags combos that had more than 40 answers over the entire year.", + tags, }; const answerArgs = { excerpt: baseArgs.excerpt, href: "#", - timestamp: "10 minutes ago", + utcTimestamp, + readableTimestamp, userAvatar: "https://avatars.githubusercontent.com/u/2", userName: "Jane Smith", userProfileUrl: "#jane-smith", - userReputation: "1,000", + userReputation: 1000, }; const contentTypeOptions = { @@ -60,8 +71,7 @@ "knowledge-article", "policy", ]; - const ExcerptSizeTypes: ExcerptSize[] = [undefined, "sm", "md", "lg"]; - const HotnessTypes: Hotness[] = [undefined, "warm", "hot", "supernova"]; + const ExcerptLinesTypes: ExcerptLines[] = [0, 1, 2, 3]; const StateTypes: State[] = [ undefined, "archived", @@ -97,13 +107,9 @@ type: "select", }, }, - excerptSize: { - control: "select", - options: ExcerptSizeTypes, - }, - hotness: { + excerptLines: { control: "select", - options: HotnessTypes, + options: ExcerptLinesTypes, }, state: { control: "select", @@ -113,28 +119,21 @@ }); +{#snippet tags()} + retrieval-augmented-generation + langchain + llm + vector-database + ai +{/snippet} + {#snippet template(args)} - - - data - data-insights - - + {/snippet} - - - - - data - data-insights - - - - - +
@@ -145,18 +144,13 @@ - {#each ExcerptSizeTypes as excerptSize (excerptSize)} + {#each ExcerptLinesTypes as excerptLines (excerptLines)} {/each} @@ -165,85 +159,29 @@ - - - - - data - data-insights - - - - - - - data - data-insights - - + - - - data - data-insights - - + - - - data - data-insights - - + - - - - - data - data-insights - - + + + - -
-
- {excerptSize || "default"} + {excerptLines} - - - data - data-insights - - +
- - - - - - - - - {#each HotnessTypes as hotness (hotness)} - - - - - {/each} - -
HotnessExample
- {hotness || "default"} - - - - data - data-insights - - -
-
+ + + @@ -269,12 +207,7 @@ name: contentTypeName, url: "#", }} - > - - data - data-insights - - + /> {/each} @@ -284,22 +217,29 @@ - - - - data - data-insights - + + + {#snippet tags()} + retrieval-augmented-generation + langchain + llm + vector-database + ai + {/snippet} - - - - data + + + {#snippet tags()} + retrieval-augmented-generation + langchain + llm + vector-database + ai data-insights - + {/snippet} @@ -320,12 +260,7 @@ {state || "default"} - - - data - data-insights - - + {/each} @@ -347,41 +282,19 @@ answerPreviews - - - + + + {#snippet answerPreviews()} - - - - - - actionMenu - - -
- -
+ {/snippet}
tags - - - - data - data-insights - - + + diff --git a/packages/stacks-svelte/src/components/PostSummary/PostSummary.svelte b/packages/stacks-svelte/src/components/PostSummary/PostSummary.svelte index b650312b5b..bd0e4e8e0e 100644 --- a/packages/stacks-svelte/src/components/PostSummary/PostSummary.svelte +++ b/packages/stacks-svelte/src/components/PostSummary/PostSummary.svelte @@ -10,8 +10,7 @@ url: string; } | undefined; - export type ExcerptSize = "sm" | "md" | "lg" | undefined; - export type Hotness = "warm" | "hot" | "supernova" | undefined; + export type ExcerptLines = 0 | 1 | 2 | 3; export type State = | "archived" | "closed" @@ -23,205 +22,248 @@ +{#snippet userBling()} + {#if userReputation} + + {/if} +{/snippet} +
-
- {#if state} - - {/if} - {#if votes || votes === 0} - - {/if} - {#if answers || answers === 0} +
+
+ {#if Number(votes) > 0}+{/if}{votes || "0"} + {i18nVotesUnit} +
+
{#if acceptedAnswer} - {i18nAcceptedAnswerIconTitle} - {:else} - + {/if} - {/if} - {#if views || views === 0} - - {/if} - {#if readTime} - - {/if} + {formatCount(answers || 0)} + {i18nAnswersUnit} +
{#if bounty} - +
+ + + {formatCount(bounty || 0)} + {i18nBountyUnit} +
{/if}
- {#if contentType} - - {/if} -

- {#if activityIndicator} -
-
{i18nActivityIndicatorText}
+
+ {#if state && state !== "deleted"} + + {/if} +
+ +
+ {#if gated} - + {/if} {title} - -

- {#if excerpt} - + +
+ {#if excerpt && excerptLines > 0} + {/if} -
- {#if $$slots.tags} -
- - -
- {/if} - {#snippet time()} - - {/snippet} - {#snippet blings()} - {#if userReputation} - + {#if contentType} + {/if} - {/snippet} - -
- - - {#if $$slots.actionMenu} - - - - - - - - - + {#if tags} + {@render tags()} + {/if} +
+ {/if} + {#if answerPreviews} +
+ {@render answerPreviews()} +
{/if}
diff --git a/packages/stacks-svelte/src/components/PostSummary/PostSummary.test.ts b/packages/stacks-svelte/src/components/PostSummary/PostSummary.test.ts index 5b839e6fa5..93f2244da2 100644 --- a/packages/stacks-svelte/src/components/PostSummary/PostSummary.test.ts +++ b/packages/stacks-svelte/src/components/PostSummary/PostSummary.test.ts @@ -6,12 +6,13 @@ import PostSummary from "./PostSummary.svelte"; const baseArgs = { answers: 10, href: "#", - timestamp: "10 minutes ago", + readableTimestamp: "asked 2 hr ago", + utcTimestamp: "2024-01-15T12:00:00.000Z", title: "Network graph of popular tags on Stack Overflow", userAvatar: "https://avatars.githubusercontent.com/u/1", userName: "Jane Smith", userProfileUrl: "#jane-smith", - userReputation: "1,000", + userReputation: 1000, views: 100, votes: 1, excerpt: @@ -24,238 +25,308 @@ const snippet = createRawSnippet(() => ({ describe("PostSummary", () => { it("should render the title as a link with the passed href", () => { - render(PostSummary, { props: baseArgs }); + render(PostSummary, { ...baseArgs }); const titleLink = screen.getByRole("link", { name: baseArgs.title }); expect(titleLink).to.exist; expect(titleLink).to.have.attr("href", baseArgs.href); - expect(titleLink).to.have.text(` ${baseArgs.title}`); + expect(titleLink.textContent?.trim()).to.equal(baseArgs.title); }); // classes - it("should render the excerpt with the appropriate size class", () => { - render(PostSummary, { props: { ...baseArgs, excerptSize: "md" } }); + it("should render the excerpt with expected number of lines", () => { + render(PostSummary, { ...baseArgs, excerptLines: 2 }); const excerpt = screen.getByText(baseArgs.excerpt); expect(excerpt).to.exist; - expect(excerpt).to.have.class("s-post-summary--content-excerpt__md"); - expect(excerpt).to.have.text(`${baseArgs.excerpt}`); + expect(excerpt).to.have.class("s-post-summary--excerpt"); + expect(excerpt).to.have.class("v-truncate2"); }); - it("should include the minimal class", () => { - render(PostSummary, { props: { ...baseArgs, minimal: true } }); + it("should not render the excerpt when excerptLines is 0", () => { + render(PostSummary, { ...baseArgs, excerptLines: 0 }); - const postSummary = document.querySelector(".s-post-summary"); - expect(postSummary).to.exist; - expect(postSummary).to.have.class("s-post-summary__minimal"); + const excerptContainer = document.querySelector( + ".s-post-summary--excerpt" + ); + expect(excerptContainer).not.to.exist; }); - it("should include the appropriate state class", () => { - render(PostSummary, { props: { ...baseArgs, state: "deleted" } }); + it("should include the answered class when acceptedAnswer is true", () => { + render(PostSummary, { ...baseArgs, acceptedAnswer: true }); const postSummary = document.querySelector(".s-post-summary"); expect(postSummary).to.exist; - expect(postSummary).to.have.class("s-post-summary__deleted"); + expect(postSummary).to.have.class("s-post-summary__answered"); }); - it("should include the watched class when ignored is true", () => { - render(PostSummary, { props: { ...baseArgs, ignored: true } }); + it("should include the appropriate state class when state is deleted", () => { + render(PostSummary, { ...baseArgs, state: "deleted" }); const postSummary = document.querySelector(".s-post-summary"); expect(postSummary).to.exist; - expect(postSummary).to.have.class("s-post-summary__ignored"); + expect(postSummary).to.have.class("s-post-summary__deleted"); }); - it("should include the watched class when watched is true", () => { - render(PostSummary, { props: { ...baseArgs, watched: true } }); + it("should apply the class prop to the root element", () => { + render(PostSummary, { ...baseArgs, class: "my-custom-class" }); const postSummary = document.querySelector(".s-post-summary"); expect(postSummary).to.exist; - expect(postSummary).to.have.class("s-post-summary__watched"); - }); - - it("should render the views stats item with the appropriate hotness class", () => { - render(PostSummary, { - props: { ...baseArgs, hotness: "hot" }, - }); - - const views = document.querySelector(".is-hot"); - expect(views).to.exist; + expect(postSummary).to.have.class("s-post-summary"); + expect(postSummary).to.have.class("my-custom-class"); }); // stats items it("should render the state badge", () => { - render(PostSummary, { props: { ...baseArgs, state: "pinned" } }); + render(PostSummary, { ...baseArgs, state: "pinned" }); - const stateBadge = screen.getByText("Pinned"); - expect(stateBadge).to.exist; + const stateBadges = screen.getAllByText("Pinned"); + expect(stateBadges.length).to.be.at.least(1); + expect(stateBadges[0]).to.exist; }); it("should render the votes stats item", () => { - render(PostSummary, { props: baseArgs }); + render(PostSummary, { ...baseArgs }); - const votes = screen.getByText("vote").parentElement; - expect(votes).to.have.text("1 vote"); + const votesContainer = document.querySelector( + ".s-post-summary--stats-votes" + ); + expect(votesContainer).to.exist; + expect(votesContainer?.textContent).to.include("1"); + expect(votesContainer?.textContent).to.include("vote"); }); it("should render the answers stats item", () => { - render(PostSummary, { props: baseArgs }); + render(PostSummary, { ...baseArgs }); - const answers = screen.getByText("answers").parentElement; - expect(answers).to.have.text("10 answers"); + const answersContainers = document.querySelectorAll( + ".s-post-summary--stats-answers" + ); + expect(answersContainers.length).to.be.at.least(1); + expect(answersContainers[0]?.textContent).to.include("10"); + expect(answersContainers[0]?.textContent).to.include("answers"); }); it("should render the views stats item", () => { - render(PostSummary, { props: baseArgs }); + render(PostSummary, { ...baseArgs }); - const views = screen.getByText("views").parentElement; - expect(views).to.have.text("100 views"); + const viewsItem = screen.getByText((content, el) => { + return ( + el?.classList.contains("s-post-summary--stats-item") === true && + content.includes("views") + ); + }); + expect(viewsItem).to.exist; + expect(viewsItem.textContent).to.include("100"); + expect(viewsItem.textContent).to.include("views"); }); it("should render the read time", () => { - render(PostSummary, { props: { ...baseArgs, readTime: "5 min read" } }); + render(PostSummary, { ...baseArgs, readTime: "5 min read" }); const readTime = screen.getByText("5 min read"); expect(readTime).to.exist; }); it("should render the bounty", () => { - render(PostSummary, { props: { ...baseArgs, bounty: 50 } }); + render(PostSummary, { ...baseArgs, bounty: 50 }); - const bounty = screen.getByText("+50"); - expect(bounty).to.exist; + const bountyContainer = document.querySelector( + ".s-post-summary--stats-bounty" + ); + expect(bountyContainer).to.exist; + expect(bountyContainer?.textContent).to.include("+"); + expect(bountyContainer?.textContent).to.include("50"); }); - // title-adjacent - it("should render the activity indicator before the title", () => { - render(PostSummary, { - props: { ...baseArgs, activityIndicator: true }, - }); - - const title = document.querySelector("h3"); - const activityIndicator = title?.querySelector(".s-activity-indicator"); - expect(activityIndicator).to.exist; - expect(activityIndicator).to.have.text(`New activity`); - }); + it("should render the comments stats item when comments is provided", () => { + render(PostSummary, { ...baseArgs, comments: 5 }); - it("should render the shield icon before the title", () => { - render(PostSummary, { - props: { ...baseArgs, gated: true }, + const commentsItem = screen.getByText((content, el) => { + return ( + el?.classList.contains("s-post-summary--stats-item") === true && + content.includes("comments") + ); }); - - const title = document.querySelector("h3"); - const shield = title?.querySelector(".iconShield"); - expect(shield).to.exist; + expect(commentsItem).to.exist; + expect(commentsItem.textContent).to.include("5"); + expect(commentsItem.textContent).to.include("comments"); }); - // slots - it("should render the tags slot", () => { - // @ts-expect-error $$slots is used to pass children while component is still using Svelte 4 syntax - render(PostSummary, { ...baseArgs, $$slots: { tags: snippet } }); + // gated + it("should render the shield icon before the title when gated", () => { + render(PostSummary, { ...baseArgs, gated: true }); - const tags = document.querySelector(".s-post-summary--meta-tags"); - expect(tags).to.exist; - expect(tags).to.have.text(`test snippet`); + const titleLink = screen.getByRole("link", { name: baseArgs.title }); + const icon = titleLink.querySelector("svg"); + expect(icon).to.exist; }); - it("should render the actionMenu slot", () => { - // @ts-expect-error $$slots is used to pass children while component is still using Svelte 4 syntax - render(PostSummary, { ...baseArgs, $$slots: { actionMenu: snippet } }); + // content type + it("should render the content type with the correct props", () => { + render(PostSummary, { + ...baseArgs, + contentType: { + name: "announcement", + url: "#announcement", + }, + }); - const contentMenuBtn = document.querySelector( - ".s-post-summary--content-menu-button" + const contentTypeLink = screen.getByText("Announcement"); + const contentType = contentTypeLink.closest( + ".s-post-summary--content-type" ); - expect(contentMenuBtn).to.exist; + expect(contentType).to.exist; + expect(contentTypeLink).to.have.attr("href", "#announcement"); }); - it("should render the answer previews slot", () => { + // snippets + it("should render the answerPreviews snippet", () => { + const answerPreviewsSnippet = createRawSnippet(() => ({ + render: () => `
answers
`, + })); render(PostSummary, { ...baseArgs, - // @ts-expect-error $$slots is used to pass children while component is still using Svelte 4 syntax - $$slots: { - answerPreviews: createRawSnippet(() => ({ - render: () => `
answers
`, - })), - }, + answerPreviews: answerPreviewsSnippet, }); + const answerPreviewsContainer = document.querySelector( + ".s-post-summary--answers" + ); + expect(answerPreviewsContainer).to.exist; const answerPreviews = document.querySelector("#answerPreviews"); expect(answerPreviews).to.exist; - expect(answerPreviews).to.have.text(`answers`); + expect(answerPreviews).to.have.text("answers"); + }); + + it("should render the tags snippet", () => { + render(PostSummary, { ...baseArgs, tags: snippet }); + + const tagsContainer = document.querySelector(".s-post-summary--tags"); + expect(tagsContainer).to.exist; + expect(tagsContainer?.textContent?.trim()).to.include("test snippet"); }); // i18n - it("should render the activity indicator with the localized text", () => { + it("should render the accepted answer icon with i18nAcceptedAnswerIconTitle", () => { render(PostSummary, { - props: { - ...baseArgs, - activityIndicator: true, - i18nActivityIndicatorText: "Nueva actividad", - }, + ...baseArgs, + acceptedAnswer: true, + i18nAcceptedAnswerIconTitle: "Respuesta aceptada", }); - const activityIndicator = screen.getByText("Nueva actividad"); - expect(activityIndicator).to.exist; + const titleText = screen.getByText("Respuesta aceptada"); + expect(titleText).to.exist; }); - it("should render the action menu button with the localized text", () => { + it("should render the answers unit with i18nAnswersUnit", () => { render(PostSummary, { ...baseArgs, - i18nActionMenuButtonText: "Menú", - // @ts-expect-error $$slots is used to pass children while component is still using Svelte 4 syntax - $$slots: { - actionMenu: snippet, - }, + i18nAnswersUnit: "respuestas", }); - const menuButton = screen.getByRole("button", { name: "Menú" }); - expect(menuButton).to.exist; + const answersUnit = screen.getAllByText("respuestas"); + expect(answersUnit.length).to.be.at.least(1); }); - it("should render the shield icon with the localized text", () => { + it("should render the bounty unit with i18nBountyUnit", () => { render(PostSummary, { - props: { - ...baseArgs, - gated: true, - i18nGatedTitle: "bloqueado", - }, + ...baseArgs, + bounty: 50, + i18nBountyUnit: "recompensa", }); - const shield = screen.getByText("bloqueado"); - expect(shield).to.exist; + const bountyUnits = screen.getAllByText("recompensa"); + expect(bountyUnits.length).to.be.at.least(1); }); - it("should render the content type component with the correct props", () => { + it("should render the comments unit with i18nCommentsUnit", () => { render(PostSummary, { - props: { - ...baseArgs, - contentType: { - name: "announcement", - url: "#announcement", - }, - }, + ...baseArgs, + comments: 5, + i18nCommentsUnit: "comentarios", }); - const contentTypeLink = screen.getByText("Announcement"); - const contentType = contentTypeLink.parentElement; - expect(contentType).to.exist; - expect(contentType).to.have.class("s-post-summary--content-type"); - expect(contentTypeLink).to.have.attr("href", "#announcement"); + const commentsItem = screen.getByText((content, el) => { + return ( + el?.classList.contains("s-post-summary--stats-item") === true && + content.includes("comentarios") + ); + }); + expect(commentsItem).to.exist; + expect(commentsItem?.textContent).to.include("comentarios"); }); - it("should render the content type with the localized text", () => { + it("should render the content type with i18nContentTypeText", () => { render(PostSummary, { - props: { - ...baseArgs, - contentType: { - name: "announcement", - url: "#announcement", - }, - i18nContentTypeText: "Anuncio", + ...baseArgs, + contentType: { + name: "announcement", + url: "#announcement", }, + i18nContentTypeText: "Anuncio", }); const contentTypeLink = screen.getByText("Anuncio"); expect(contentTypeLink).to.exist; }); + + it("should render the shield icon with i18nGatedTitle", () => { + render(PostSummary, { + ...baseArgs, + gated: true, + i18nGatedTitle: "bloqueado", + }); + + const titleText = screen.getByText("bloqueado"); + expect(titleText).to.exist; + }); + + it("should render the reputation bling with i18nReputationBlingName", () => { + render(PostSummary, { + ...baseArgs, + i18nReputationBlingName: "puntos de reputación", + }); + + const blingName = screen.getByText("puntos de reputación"); + expect(blingName).to.exist; + }); + + it("should render the state badge with i18nStateBadgeText", () => { + render(PostSummary, { + ...baseArgs, + state: "pinned", + i18nStateBadgeText: "Fijado", + }); + + const stateBadgeText = screen.getAllByText("Fijado"); + expect(stateBadgeText.length).to.be.at.least(1); + }); + + it("should render the views unit with i18nViewsUnit", () => { + render(PostSummary, { + ...baseArgs, + i18nViewsUnit: "vistas", + }); + + const viewsUnit = screen.getByText((content, el) => { + return ( + el?.classList.contains("s-post-summary--stats-item") === true && + content.includes("vistas") + ); + }); + expect(viewsUnit).to.exist; + expect(viewsUnit?.textContent).to.include("vistas"); + }); + + it("should render the votes unit with i18nVotesUnit", () => { + render(PostSummary, { + ...baseArgs, + i18nVotesUnit: "voto", + }); + + const votesUnit = screen.getByText("voto"); + expect(votesUnit).to.exist; + }); }); diff --git a/packages/stacks-svelte/src/components/PostSummary/PostSummaryAnswer.svelte b/packages/stacks-svelte/src/components/PostSummary/PostSummaryAnswer.svelte index 9e1055e0b6..4b952bd346 100644 --- a/packages/stacks-svelte/src/components/PostSummary/PostSummaryAnswer.svelte +++ b/packages/stacks-svelte/src/components/PostSummary/PostSummaryAnswer.svelte @@ -4,115 +4,151 @@ -
-
- - {#if accepted} - - {/if} -
-

{excerpt}

-
- {i18nViewAnswersText} - {#snippet time()} - - {/snippet} - {#snippet blings()} - {#if userReputation} - - {/if} - {/snippet} + {/if} +{/snippet} + +
+ +
diff --git a/packages/stacks-svelte/src/components/PostSummary/PostSummaryAnswer.test.ts b/packages/stacks-svelte/src/components/PostSummary/PostSummaryAnswer.test.ts index 7360023208..5547656114 100644 --- a/packages/stacks-svelte/src/components/PostSummary/PostSummaryAnswer.test.ts +++ b/packages/stacks-svelte/src/components/PostSummary/PostSummaryAnswer.test.ts @@ -6,7 +6,8 @@ const baseArgs = { href: "#", excerpt: "I wanted to see how different tags related to each other. The below graph depicts associations between popular tags on our site.", - timestamp: "10 minutes ago", + readableTimestamp: "answered 2 hr ago", + utcTimestamp: "2024-01-15T12:00:00.000Z", userAvatar: "https://avatars.githubusercontent.com/u/2", userName: "Jane Smith", userReputation: "1,000", @@ -20,29 +21,19 @@ describe("PostSummaryAnswer", () => { const excerpt = screen.getByText(baseArgs.excerpt); expect(excerpt).to.exist; - expect(excerpt).to.have.class("s-post-summary--answer-excerpt"); + expect(excerpt).to.have.class("s-post-summary--excerpt"); expect(excerpt).to.have.text(baseArgs.excerpt); }); - it("should render the view answers link with the passed href", () => { - render(PostSummaryAnswer, { ...baseArgs }); - - const viewAnswersLink = screen.getByRole("link", { - name: "View answers", - }); - expect(viewAnswersLink).to.exist; - expect(viewAnswersLink).to.have.attr("href", baseArgs.href); - }); - - it("should render the accepted answer badge when accepted is true", () => { + it("should render the accepted answer stats when accepted is true", () => { render(PostSummaryAnswer, { ...baseArgs, accepted: true }); const acceptedItem = screen.getByText("Accepted answer", { - selector: ".s-post-summary--stats-item", + selector: ".s-post-summary--stats-answers", }); expect(acceptedItem).to.exist; expect(acceptedItem?.textContent?.trim()).to.equal("Accepted answer"); - expect(acceptedItem).to.have.class("s-post-summary--stats-item"); + expect(acceptedItem).to.have.class("s-post-summary--stats-answers"); }); it("should render the user card with the correct props", () => { @@ -69,35 +60,31 @@ describe("PostSummaryAnswer", () => { const reputation = screen.getByText(baseArgs.userReputation); expect(reputation).to.exist; - const timestamp = screen.getByText(baseArgs.timestamp); - expect(timestamp).to.exist; + const timeLink = document.querySelector(".s-user-card--time"); + expect(timeLink).to.exist; + expect(timeLink?.querySelector("time")).to.exist; }); - // i18n - it("should render the accepted answer badge with the localized text", () => { - render(PostSummaryAnswer, { - ...baseArgs, - accepted: true, - i18nAcceptedAnswerText: "Respuesta aceptada", - }); + it("should render the votes count", () => { + render(PostSummaryAnswer, { ...baseArgs }); - const acceptedBadge = screen.getByText("Respuesta aceptada", { - selector: ".s-badge", - }); - expect(acceptedBadge).to.exist; - expect(acceptedBadge).to.have.class("s-post-summary--stats-item"); + const votesEl = document.querySelector(".s-post-summary--stats-votes"); + expect(votesEl).to.exist; + expect(votesEl?.textContent?.trim()).to.include("5"); }); - it("should render the view answers link with the localized text", () => { + // i18n + it("should render the accepted answer stats with the localized text", () => { render(PostSummaryAnswer, { ...baseArgs, - i18nViewAnswersText: "Ver respuestas", + accepted: true, + i18nAcceptedAnswerText: "Respuesta aceptada", }); - const viewAnswersLink = screen.getByRole("link", { - name: "Ver respuestas", + const acceptedStats = screen.getByText("Respuesta aceptada", { + selector: ".s-post-summary--stats-answers", }); - expect(viewAnswersLink).to.exist; - expect(viewAnswersLink).to.have.attr("href", baseArgs.href); + expect(acceptedStats).to.exist; + expect(acceptedStats).to.have.class("s-post-summary--stats-answers"); }); }); diff --git a/packages/stacks-svelte/src/components/PostSummary/PostSummaryContentType.test.ts b/packages/stacks-svelte/src/components/PostSummary/PostSummaryContentType.test.ts index fce2b31d2e..78c0e2707b 100644 --- a/packages/stacks-svelte/src/components/PostSummary/PostSummaryContentType.test.ts +++ b/packages/stacks-svelte/src/components/PostSummary/PostSummaryContentType.test.ts @@ -1,22 +1,17 @@ -import { createRawSnippet } from "svelte"; import { expect } from "@open-wc/testing"; import { render, screen } from "@testing-library/svelte"; import PostSummaryContentType from "./PostSummaryContentType.svelte"; -const snippet = createRawSnippet(() => ({ - render: () => "test content", -})); - describe("PostSummaryContentType", () => { it("should render the content type", () => { render(PostSummaryContentType, { href: "#", name: "announcement", - // @ts-expect-error $$slots is used to pass children while component is still using Svelte 4 syntax - $$slots: { default: snippet }, }); - const contentType = screen.getByText("Announcement").parentElement; + const contentType = screen + .getByText("Announcement") + .closest(".s-post-summary--content-type"); expect(contentType).to.exist; expect(contentType).to.have.class("s-post-summary--content-type"); }); diff --git a/packages/stacks-svelte/src/components/PostSummary/PostSummaryExcerpt.svelte b/packages/stacks-svelte/src/components/PostSummary/PostSummaryExcerpt.svelte index add970f6e4..cd4cdb834b 100644 --- a/packages/stacks-svelte/src/components/PostSummary/PostSummaryExcerpt.svelte +++ b/packages/stacks-svelte/src/components/PostSummary/PostSummaryExcerpt.svelte @@ -1,5 +1,5 @@ - - - -
- - {#if variant === "bounty"}+{/if}{number} - - {#if variant !== "bounty"} - {unit} - {/if} -
diff --git a/packages/stacks-svelte/src/components/PostSummary/PostSummaryStatsItem.test.ts b/packages/stacks-svelte/src/components/PostSummary/PostSummaryStatsItem.test.ts deleted file mode 100644 index ac1d2f8cfa..0000000000 --- a/packages/stacks-svelte/src/components/PostSummary/PostSummaryStatsItem.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { expect } from "@open-wc/testing"; -import { render, screen } from "@testing-library/svelte"; -import PostSummaryStatsItem from "./PostSummaryStatsItem.svelte"; - -describe("PostSummaryStatsItem", () => { - it("should render the unit and number", () => { - render(PostSummaryStatsItem, { - number: 123, - unit: "views", - variant: "views", - }); - - const itemText = screen - .getByText("views") - .closest(".s-post-summary--stats-item"); - expect(itemText).to.exist; - expect(itemText?.textContent).to.equal("123 views"); - }); - - it("should apply the correct class for variant", () => { - render(PostSummaryStatsItem, { - number: 50, - unit: "", - variant: "bounty", - }); - - const item = screen - .getByText("+50") - .closest(".s-post-summary--stats-item"); - ["s-badge", "s-badge__bounty"].forEach((className) => { - expect(item).to.have.class(className); - }); - }); - - it("should apply the `has-answers` class when appropriate", () => { - render(PostSummaryStatsItem, { - number: 5, - unit: "answers", - variant: "answers", - }); - - const item = screen - .getByText("answers") - .closest(".s-post-summary--stats-item"); - expect(item).to.have.class("has-answers"); - }); - - it("should apply the correct class for hotness", () => { - render(PostSummaryStatsItem, { - hotness: "hot", - number: 123, - unit: "views", - }); - - const item = screen - .getByText("views") - .closest(".s-post-summary--stats-item"); - expect(item).to.have.class("is-hot"); - }); - - it("should apply emphasis to the votes variant", () => { - render(PostSummaryStatsItem, { - number: 5, - unit: "votes", - variant: "votes", - }); - - const item = screen - .getByText("votes") - .closest(".s-post-summary--stats-item"); - expect(item).to.have.class("s-post-summary--stats-item__emphasized"); - }); -}); diff --git a/packages/stacks-svelte/src/components/UserCard/UserCardTime.svelte b/packages/stacks-svelte/src/components/UserCard/UserCardTime.svelte index 5f3de619d1..b56f36542b 100644 --- a/packages/stacks-svelte/src/components/UserCard/UserCardTime.svelte +++ b/packages/stacks-svelte/src/components/UserCard/UserCardTime.svelte @@ -1,18 +1,20 @@