@redseed/redseed-ui-vue3 11.0.2 → 11.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redseed/redseed-ui-vue3",
3
- "version": "11.0.2",
3
+ "version": "11.2.0",
4
4
  "description": "RedSeed UI Vue 3 components",
5
5
  "main": "index.js",
6
6
  "repository": "https://github.com/redseedtraining/redseed-ui",
@@ -46,6 +46,23 @@ const props = defineProps({
46
46
  type: Boolean,
47
47
  default: true,
48
48
  },
49
+ /**
50
+ * Keep the actions in the header row, whatever the container measures.
51
+ *
52
+ * The swap this overrides treats a narrow container as a phone and drops the actions
53
+ * under the title. `CardHorizontal` is never a phone: its header sits beside a fixed
54
+ * thumbnail rail inside a body column, so it is narrow by construction at every width.
55
+ *
56
+ * Opt-in and `false` by default, so no existing card moves. Scoped rather than a change
57
+ * to the rule itself, because the rule is right for a Card — measured on
58
+ * `InNarrowContainer`, two labelled buttons take 244px of a 398px header and the avatar
59
+ * takes its share before the title sees any, leaving the title 48px. Capping the toolbar
60
+ * does not rescue that either; it leaves the title at 95px.
61
+ */
62
+ inlineActions: {
63
+ type: Boolean,
64
+ default: false,
65
+ },
49
66
  /**
50
67
  * Force the overflow menu on, or suppress it.
51
68
  *
@@ -155,6 +172,7 @@ const isColumnOnAWiderPage = useColumnOnAWiderPage(cardHeaderWidth)
155
172
  * refs read below are still tracked, because the render effect is what calls this.
156
173
  */
157
174
  function keepsActionsInline() {
175
+ if (props.inlineActions) return true
158
176
  if (responsiveWidth.value.specific) return true
159
177
 
160
178
  return isColumnOnAWiderPage.value && countsRenderedNodes(slots.actions) === 1
@@ -1,7 +1,6 @@
1
1
  <script setup>
2
- import { ref, computed } from 'vue'
3
- import { useResizeObserver, useMutationObserver } from '@vueuse/core'
4
2
  import Card from './Card.vue'
3
+ import CardHeader from './CardHeader.vue'
5
4
 
6
5
  const props = defineProps({
7
6
  /**
@@ -41,72 +40,16 @@ const props = defineProps({
41
40
 
42
41
  defineEmits(['click'])
43
42
 
44
- /* ── Floating thumbnail detection ────────────────────────────────────────────
45
- Adds --floating to the thumbnail whenever the image does not reach the card
46
- bottom, so CSS can restore the image's bottom-right radius.
47
-
48
- Two independent conditions trigger floating. clientHeight is integer-rounded, so
49
- strict `<` comparisons are stable — sub-pixel gaps (< 0.5 px) round to 0 and
50
- produce equal clientHeights, leaving truly-flush images unaffected:
51
- 1. imgH > 0 && imgH < thumbH
52
- Landscape: the loaded img is shorter than the 160px rail.
53
- The imgH > 0 guard avoids a false positive on square cards while the
54
- Image component's picture is still v-show=false (img.clientHeight = 0).
55
- 2. thumbH < cardContentH
56
- Tall-body: the body column is taller than the 160px thumbnail.
57
-
58
- NOTE: thumbIsFloating applies --floating in both stacked (mobile) and rail
59
- (≥512px container) layouts. In the stacked layout the --floating CSS rules are
60
- scoped to the @container/card-horizontal block so they have no visual effect —
61
- the flag may be true in the stacked layout but is intentionally not guarded here.
62
-
63
- useResizeObserver watches three targets: the thumbnail element, its parent
64
- (content wrapper), and the currently-rendered img (currentImg ref — auto-retargets
65
- via vueuse's reactive watch when the img reference changes, replacing the manual
66
- unobserve/observe swap in the previous hand-rolled implementation).
67
- useMutationObserver fires updateFloating when DOM children change in the thumbnail
68
- subtree — catches Image component adding its <img> when originalUrl is set after
69
- the card has already mounted (skeleton-first / data-loaded-later pattern).
70
- All observers auto-cleanup on scope dispose (no onMounted/onUnmounted needed). */
71
-
72
- const thumbEl = ref(null)
73
- const thumbIsFloating = ref(false)
74
- const currentImg = ref(null)
75
- const thumbParent = computed(() => thumbEl.value?.parentElement ?? null)
76
-
77
- function updateFloating() {
78
- const thumb = thumbEl.value
79
- if (!thumb) return
80
-
81
- // Scoped img lookup: prefer the img inside an rsui-image wrapper (the common
82
- // case), then fall back to a raw direct-child img. The :scope > img limit
83
- // prevents a badge or avatar img nested deeper in the slot from being measured.
84
- const img =
85
- thumb.querySelector('.rsui-image img') ||
86
- thumb.querySelector(':scope > img')
87
-
88
- // Keep currentImg in sync so useResizeObserver(currentImg) auto-retargets
89
- // the img observer when a late-appearing element replaces the previous one.
90
- currentImg.value = img ?? null
91
-
92
- const imgH = img ? img.clientHeight : 0
93
- const thumbH = thumb.clientHeight
94
- const cardContentH = thumb.parentElement ? thumb.parentElement.clientHeight : thumbH
95
- // imgH > 0 guard on condition 1: while the Image component's picture is v-show=false
96
- // (loading / empty state), img.clientHeight is 0. Without the guard a square card would
97
- // incorrectly receive --floating at mount; the class self-corrects once the image loads
98
- // and condition 1 can be re-evaluated with the real rendered height.
99
- //
100
- // Strict comparison (no px tolerance): clientHeight is integer-rounded, so a truly-flush
101
- // image (sub-pixel gap < 0.5 px) produces equal integer heights and the condition is
102
- // false. ANY visible gap (≥ 1 integer px) correctly triggers floating.
103
- thumbIsFloating.value = (imgH > 0 && imgH < thumbH) || (thumbH < cardContentH)
104
- }
105
-
106
- useResizeObserver(thumbEl, updateFloating)
107
- useResizeObserver(thumbParent, updateFloating)
108
- useResizeObserver(currentImg, updateFloating)
109
- useMutationObserver(thumbEl, updateFloating, { childList: true, subtree: true })
43
+ /*
44
+ * No floating-thumbnail detection any more.
45
+ *
46
+ * This used to run three ResizeObservers and a MutationObserver per card to work out
47
+ * whether the image reached the card's bottom, and add `--floating` so the CSS could
48
+ * put a radius back on its bottom-right corner. That answered a question the rail no
49
+ * longer asks: the image now covers a rail that fills the card's full height, so it
50
+ * always reaches the bottom and never carries a radius of its own. See #440 and the
51
+ * removal note in card_horizontal.css.
52
+ */
110
53
  </script>
111
54
  <template>
112
55
  <Card
@@ -125,25 +68,59 @@ useMutationObserver(thumbEl, updateFloating, { childList: true, subtree: true })
125
68
  </template>
126
69
 
127
70
  <!-- thumbnail slot: required for the horizontal rail layout.
128
- Pass an Image component (originalUrl + alt) to populate the 160px column.
129
- An empty slot renders a blank 160px rail gap in the rail layout. -->
130
- <div
131
- ref="thumbEl"
132
- class="rsui-card-horizontal__thumbnail"
133
- :class="{ 'rsui-card-horizontal__thumbnail--floating': thumbIsFloating }"
134
- >
71
+ Pass an Image component (originalUrl + alt) to fill the rail — its width is
72
+ --ch-thumbnail-size and its height is the card's, and the image covers it
73
+ whatever its source ratio. An empty slot renders a blank rail. -->
74
+ <div class="rsui-card-horizontal__thumbnail">
135
75
  <slot name="thumbnail"></slot>
136
76
  </div>
137
77
 
138
78
  <div class="rsui-card-horizontal__body">
139
- <div class="rsui-card-horizontal__content">
140
- <div v-if="$slots.title" class="rsui-card-horizontal__title">
141
- <slot name="title"></slot>
142
- </div>
143
- <div v-if="$slots.description" class="rsui-card-horizontal__description">
79
+ <!--
80
+ CardHeader, not a title and a description of its own.
81
+
82
+ This used to hand-roll both, and the stylesheet admitted it: `__title` carried
83
+ "The same as .rsui-card-header__title — brand Header 4, Inter Semibold 20/26",
84
+ and `__meta` "Matches .rsui-card__meta". Both of those comments exist because
85
+ the copies had already drifted once and had to be re-synced by hand.
86
+
87
+ Composing instead means the title role, the subtitle and the actions row come
88
+ from the component that defines them, and the only thing this file still owns is
89
+ the thumbnail rail and the meta region beneath it.
90
+
91
+ It also gives the card somewhere to put ACTIONS. There was no slot for them, so
92
+ the one consumer in the product puts its favourite and download buttons inside
93
+ `#meta` and forces a line break with `w-full` — a row invented at the call site
94
+ because the component offered nowhere else.
95
+
96
+ `singleLine` is false: CardHeader clamps a subtitle to one line at `@md`, and a
97
+ description here runs to two or three. Nothing clamps it in their place —
98
+ see the subtitle note at the foot of card_horizontal.css.
99
+ -->
100
+ <CardHeader
101
+ :showAvatar="false"
102
+ :singleLine="false"
103
+ inlineActions
104
+ :showMoreActions="$slots['more-actions'] ? null : false"
105
+ >
106
+ <slot name="title"></slot>
107
+
108
+ <template v-if="$slots.badge" #badge>
109
+ <slot name="badge"></slot>
110
+ </template>
111
+
112
+ <template v-if="$slots.description" #subtitle>
144
113
  <slot name="description"></slot>
145
- </div>
146
- </div>
114
+ </template>
115
+
116
+ <template v-if="$slots.actions" #actions>
117
+ <slot name="actions"></slot>
118
+ </template>
119
+
120
+ <template v-if="$slots['more-actions']" #more-actions>
121
+ <slot name="more-actions"></slot>
122
+ </template>
123
+ </CardHeader>
147
124
 
148
125
  <div v-if="$slots.meta" class="rsui-card-horizontal__meta">
149
126
  <slot name="meta"></slot>
@@ -156,22 +156,36 @@ function toggle() {
156
156
  </a>
157
157
  <Badge v-if="category.href === ''" neutral>{{ category.label }}</Badge>
158
158
  </li>
159
- </ul>
160
159
 
161
- <!--
162
- Outside the list, because the toggle is not a category. Sibling to the `ul`
163
- rather than inside it so the list keeps its semantics: `display: contents` on a
164
- `ul` would let the items join this row directly, but it also drops list
165
- semantics in Chrome and Safari, which is the one thing the row exists to keep.
166
- -->
167
- <button v-if="isCapped"
168
- type="button"
169
- class="rsui-category-list__more"
170
- :aria-label="toggleAccessibleName"
171
- :aria-expanded="isExpanded"
172
- @click="toggle"
173
- >
174
- {{ toggleText }}
175
- </button>
160
+ <!--
161
+ INSIDE the list, in its own `li`.
162
+
163
+ It was a sibling of the `ul`, to keep the toggle out of a list of categories
164
+ — it is a control, not a category. The cost was a layout one and it was not
165
+ small: a flex item is atomic, so the button could sit beside the whole list
166
+ or below it, never after the last badge. Once the badges wrapped to a second
167
+ row the toggle took a third, with the second row's free space beside it.
168
+ Measured at 700px: the last badge ended at 180px with 488px empty to its
169
+ right, and the control sat below at x=0. In the list, it lands at 196 and the
170
+ component is 60px tall instead of 88.
171
+
172
+ `display: contents` on the `ul` would have joined the rows without this, and
173
+ is why the button was outside in the first place — but it drops list
174
+ semantics in Chrome and Safari, which is the one thing the row exists to keep.
175
+ An `li` holding a button keeps them: the list is still a list, with the
176
+ control that governs it as its last item.
177
+ -->
178
+ <li v-if="isCapped" class="rsui-category-list__more-item">
179
+ <button
180
+ type="button"
181
+ class="rsui-category-list__more"
182
+ :aria-label="toggleAccessibleName"
183
+ :aria-expanded="isExpanded"
184
+ @click="toggle"
185
+ >
186
+ {{ toggleText }}
187
+ </button>
188
+ </li>
189
+ </ul>
176
190
  </div>
177
191
  </template>
@@ -125,9 +125,9 @@ const defaultSize = computed(() =>
125
125
  )
126
126
 
127
127
  /**
128
- * Default color is primary if no color is specified.
128
+ * Whether the consumer named a tone at all.
129
129
  */
130
- const defaultColor = computed(() =>
130
+ const hasNoTone = computed(() =>
131
131
  !props.primary
132
132
  && !props.secondary
133
133
  && !props.brand
@@ -158,7 +158,29 @@ const iconClass = computed(() => [
158
158
  /**
159
159
  * Colors
160
160
  */
161
- 'rsui-icon--primary': props.primary || defaultColor.value,
161
+ /**
162
+ * No tone means INHERIT, not primary.
163
+ *
164
+ * The tone rules paint the svg (`*:text-…`), and a colour on the svg wins over
165
+ * anything around it — so defaulting to `primary` made an icon inside a coloured
166
+ * control render near-black while the control beside it was green. A colour on the
167
+ * wrapper would have been inherited and therefore overridable, which is the
168
+ * property that was missing. With no class at all the svg's own `currentColor`
169
+ * resolves against whatever encloses it. See #428.
170
+ *
171
+ * Anywhere the surroundings are already primary ink — body text, headings, most of
172
+ * a page — that is the same colour it was. Only icons in a coloured control move.
173
+ *
174
+ * `background` is the exception, and it is not a leftover. It paints a CONTRAST
175
+ * SURFACE behind the glyph, and which surface is a question only a tone can answer:
176
+ * `.rsui-icon--primary.rsui-icon--background` is `bg-background-primary-contrast`.
177
+ * Without a fallback `IconCircleBackground` and `IconRoundedBackground` — neither
178
+ * of which passes a tone — would render with no background at all.
179
+ *
180
+ * `invert` needed the same partner and no longer does: it carries
181
+ * `*:text-text-on-color` itself now, which is what it always meant.
182
+ */
183
+ 'rsui-icon--primary': props.primary || (hasNoTone.value && props.background),
162
184
  'rsui-icon--secondary': props.secondary,
163
185
  'rsui-icon--brand': props.brand,
164
186
  'rsui-icon--success': props.success,