@redseed/redseed-ui-vue3 8.62.0 → 8.64.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.
@@ -1,25 +1,215 @@
1
1
  <script setup>
2
- defineProps({
2
+ import { computed, watchEffect, useSlots, Comment, Fragment } from 'vue'
3
+
4
+ const props = defineProps({
3
5
  showContent: {
4
6
  type: Boolean,
5
7
  default: true,
6
8
  },
9
+ /**
10
+ * Label for the trailing "view all" link. Setting it, together with
11
+ * `viewAllHref`, is what puts the link in the footer.
12
+ *
13
+ * A prop rather than a slot: it is the link's accessible name, and a prop
14
+ * cannot be present-but-empty the way a slot can. Slot presence read through
15
+ * `useSlots()` is also not reactive, so a link gated on it would never appear
16
+ * for a consumer rendering it conditionally.
17
+ */
18
+ viewAllLabel: {
19
+ type: String,
20
+ default: '',
21
+ },
22
+ /**
23
+ * Where the view-all link goes. Required alongside the label — an anchor
24
+ * without an href is not a link, and is skipped by keyboard navigation.
25
+ */
26
+ viewAllHref: {
27
+ type: String,
28
+ default: '',
29
+ },
30
+ })
31
+
32
+ /**
33
+ * Coerced, not dereferenced. `default: ''` only applies to `undefined` — Vue
34
+ * skips type checking entirely for a `null` on a non-required prop, so `null`
35
+ * arrives with no warning at all and `.trim()` on it throws out of setup, taking
36
+ * the whole parent subtree down with it. An absent URL serialises to `null`, not
37
+ * `undefined`, so that is the likely wiring rather than the exotic one. The type
38
+ * validator cannot be relied on either; it is stripped from production builds.
39
+ */
40
+ const viewAllLabelText = computed(() => String(props.viewAllLabel ?? '').trim())
41
+
42
+ const rawViewAllHref = computed(() => String(props.viewAllHref ?? '').trim())
43
+
44
+ /**
45
+ * Schemes an anchor may safely carry. `javascript:` and `data:` in an href are
46
+ * script execution, and this is a declared prop on a design-system component
47
+ * rather than an attribute passthrough — a consuming app can reasonably expect
48
+ * the anchor it did not write to be safe. Anything else is refused loudly, since
49
+ * a link that quietly stops working is the failure this component exists to
50
+ * avoid.
51
+ */
52
+ const SAFE_HREF_SCHEME = /^(https?:|mailto:|tel:)/i
53
+
54
+ /**
55
+ * `//evil.com` is protocol-relative: it starts with a slash but navigates to
56
+ * another host entirely, so a leading-slash test alone would wave it through.
57
+ */
58
+ const PROTOCOL_RELATIVE = /^\/\//
59
+
60
+ /**
61
+ * Anything with a scheme has one before the first slash. No colon in that span
62
+ * means a relative path — `courses/1`, `./x`, `../x`, `#anchor`, `?q=1` — which
63
+ * cannot leave the site and so needs no allow-list of its own. This is what the
64
+ * warning text has always told consumers to use; the previous check only accepted
65
+ * paths beginning with `/`, and refused the rest while telling them they were
66
+ * fine.
67
+ */
68
+ function isRelativeHref(href) {
69
+ const beforeFirstSlash = href.split('/')[0]
70
+
71
+ return !beforeFirstSlash.includes(':')
72
+ }
73
+
74
+ const isViewAllHrefSafe = computed(() => {
75
+ const href = rawViewAllHref.value
76
+
77
+ if (href === '') return true
78
+ if (PROTOCOL_RELATIVE.test(href)) return false
79
+ if (SAFE_HREF_SCHEME.test(href)) return true
80
+
81
+ return isRelativeHref(href)
82
+ })
83
+
84
+ const hasViewAllLabel = computed(() => viewAllLabelText.value !== '')
85
+
86
+ const hasViewAllHref = computed(() => rawViewAllHref.value !== '' && isViewAllHrefSafe.value)
87
+
88
+ const showViewAll = computed(() => hasViewAllLabel.value && hasViewAllHref.value)
89
+
90
+ const slots = useSlots()
91
+
92
+ /**
93
+ * Whether a slot renders anything at all. Slot *presence* is not the same
94
+ * question: `<template #default><Button v-if="canManage" /></template>` leaves a
95
+ * truthy slot function that renders a comment placeholder, which would still
96
+ * claim a `flex-1` region and shove the centred link into one half of the footer.
97
+ */
98
+ function rendersContent(slot) {
99
+ const nodes = slot?.()
100
+
101
+ if (!nodes) return false
102
+
103
+ // Comment vnodes are what a falsy `v-if` leaves behind. Fragments wrap lists
104
+ // and `<template v-if>` blocks, so a truthy wrapper around a falsy child is a
105
+ // Fragment whose only child is a Comment — counting its length would call that
106
+ // content and render an empty region claiming `flex-1`.
107
+ return nodes.some(rendersNode)
108
+ }
109
+
110
+ function rendersNode(node) {
111
+ if (node.type === Comment) return false
112
+ if (node.type === Fragment) return Array.isArray(node.children) && node.children.some(rendersNode)
113
+
114
+ return true
115
+ }
116
+
117
+ /**
118
+ * Whether the view-all has the footer to itself, which is the case that wants
119
+ * centring. With anything alongside it, a greedy region would split the row with
120
+ * `content-start` and squeeze the buttons towards the middle rather than the
121
+ * right edge, so it stays content-sized and sits at the end instead.
122
+ */
123
+ /*
124
+ * Plain functions, not computeds. `useSlots()` returns a non-reactive object, so
125
+ * a computed wrapping it caches its first answer forever — a consumer putting a
126
+ * `v-if` on the slot itself would never see the region appear or disappear. Called
127
+ * from the template instead, so they are re-evaluated on every render, which is
128
+ * what makes them track.
129
+ */
130
+ function hasDefaultContent() {
131
+ return rendersContent(slots.default)
132
+ }
133
+
134
+ function hasStartContent() {
135
+ return props.showContent && rendersContent(slots.content)
136
+ }
137
+
138
+ function isViewAllAlone() {
139
+ return !hasDefaultContent() && !hasStartContent()
140
+ }
141
+
142
+ /**
143
+ * Half a link is not a link, so say so rather than rendering nothing. A label
144
+ * with no href would be an anchor the keyboard skips; an href with no label
145
+ * would be one a screen reader announces as its own URL.
146
+ *
147
+ * Deferred by a tick, and cancelled if the effect re-runs. The two values often
148
+ * arrive separately — a literal label beside an href that is empty until a fetch
149
+ * resolves — and warning about a state that corrects itself a tick later trains
150
+ * everyone to ignore the channel, which costs more than it catches.
151
+ *
152
+ * Dev-only, matching LinkSlot, ButtonSlot, Pill and InlineEditableText. This
153
+ * package ships raw `.vue` source, so an ungated warn reaches real users'
154
+ * consoles.
155
+ */
156
+ watchEffect((onCleanup) => {
157
+ if (process.env.NODE_ENV === 'production') return
158
+ if (showViewAll.value) return
159
+
160
+ const isUnsafeHref = rawViewAllHref.value !== '' && !isViewAllHrefSafe.value
161
+ const isHalfConfigured = hasViewAllLabel.value !== hasViewAllHref.value
162
+
163
+ if (!isUnsafeHref && !isHalfConfigured) return
164
+
165
+ const message = isUnsafeHref
166
+ ? `[RSUI] SectionFooter: refused an unsafe \`viewAllHref\` ("${rawViewAllHref.value}"). Use an http(s), mailto, tel, or relative URL.`
167
+ : `[RSUI] SectionFooter: the view-all link needs both \`viewAllLabel\` and \`viewAllHref\`; \`${hasViewAllLabel.value ? 'viewAllHref' : 'viewAllLabel'}\` is missing, so no link has been rendered.`
168
+
169
+ const timer = setTimeout(() => console.warn(message), 0)
170
+
171
+ onCleanup(() => clearTimeout(timer))
7
172
  })
8
173
  </script>
9
174
  <template>
10
175
  <div class="rsui-section-footer">
11
176
  <div class="rsui-section-footer__content-start"
12
- v-if="showContent && $slots.content"
177
+ v-if="hasStartContent()"
13
178
  >
14
179
  <slot name="content"></slot>
15
180
  </div>
16
- <div :class="[
17
- 'rsui-section-footer__content-end',
18
- {
19
- 'rsui-section-footer__content-end--full': !showContent || !$slots.content,
20
- }
21
- ]">
181
+ <div v-if="hasDefaultContent()"
182
+ :class="[
183
+ 'rsui-section-footer__content-end',
184
+ {
185
+ 'rsui-section-footer__content-end--full': !hasStartContent(),
186
+ }
187
+ ]"
188
+ >
22
189
  <slot></slot>
23
190
  </div>
191
+
192
+ <!--
193
+ Its own centred region rather than tucked into `content-end`, which is
194
+ right-aligned for action buttons. A view-all is not a form action sitting
195
+ at the end of a row — it is the way out of the section, and it usually has
196
+ the footer to itself.
197
+ -->
198
+ <div v-if="showViewAll"
199
+ :class="[
200
+ 'rsui-section-footer__view-all-region',
201
+ {
202
+ 'rsui-section-footer__view-all-region--full': isViewAllAlone(),
203
+ }
204
+ ]"
205
+ >
206
+ <a class="rsui-section-footer__view-all"
207
+ :href="rawViewAllHref"
208
+ data-ph-capture-attribute-component="SectionFooterViewAll"
209
+ data-ph-capture-attribute-label="View all"
210
+ >
211
+ {{ viewAllLabelText }}
212
+ </a>
213
+ </div>
24
214
  </div>
25
215
  </template>
@@ -22,9 +22,14 @@ const props = defineProps({
22
22
  type: Boolean,
23
23
  default: true,
24
24
  },
25
+ // Default false. It was true, but no CSS rule existed behind the --divider class,
26
+ // so every consumer has been seeing no rule regardless. The rule exists now; keeping
27
+ // the default at true would make hairlines appear under every section header in every
28
+ // app off the back of a spacing fix. False preserves what they actually see today and
29
+ // makes the prop work for anyone who opts in. Matches CardHeader.
25
30
  showDivider: {
26
31
  type: Boolean,
27
- default: true,
32
+ default: false,
28
33
  },
29
34
  noClamp: {
30
35
  type: Boolean,