@redseed/redseed-ui-vue3 8.64.0 → 8.66.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/index.js CHANGED
@@ -12,6 +12,7 @@ export * from './src/components/Button'
12
12
  export * from './src/components/ButtonGroup'
13
13
  export * from './src/components/Card'
14
14
  export * from './src/components/CardGroup'
15
+ export * from './src/components/Category'
15
16
  export * from './src/components/Chart'
16
17
  export * from './src/components/Comment'
17
18
  export * from './src/components/Disclosure'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redseed/redseed-ui-vue3",
3
- "version": "8.64.0",
3
+ "version": "8.66.0",
4
4
  "description": "RedSeed UI Vue 3 components",
5
5
  "main": "index.js",
6
6
  "repository": "https://github.com/redseedtraining/redseed-ui",
@@ -0,0 +1,177 @@
1
+ <script setup>
2
+ import { ref, computed } from 'vue'
3
+ import Badge from '../Badge/Badge.vue'
4
+ import { isSafeHref } from '../../helpers/href'
5
+
6
+ const props = defineProps({
7
+ /**
8
+ * The categories, in the order the record gives them. Entries are either a string
9
+ * or `{ label, href }` — see `href` below for when the second form applies.
10
+ *
11
+ * Never sorted. The first category is usually the primary one, so alphabetising
12
+ * throws away the only ordering information the record carries.
13
+ */
14
+ items: {
15
+ type: Array,
16
+ default: () => [],
17
+ },
18
+ /**
19
+ * Accessible name for the list. A bare row of badges is four unexplained words to a
20
+ * screen reader; named, it announces as "Categories, list, 4 items".
21
+ */
22
+ label: {
23
+ type: String,
24
+ default: 'Categories',
25
+ },
26
+ /**
27
+ * How many categories show before the rest are counted. Four is the cap the header
28
+ * standards set: eight categories is four lines of a phone screen before the meta
29
+ * even starts.
30
+ *
31
+ * Zero — or anything below it — caps nothing, for the rare row that must show in
32
+ * full. There is deliberately no horizontal-scroller option, for the same reason a
33
+ * table never scrolls the page sideways.
34
+ */
35
+ cap: {
36
+ type: Number,
37
+ default: 4,
38
+ },
39
+ /**
40
+ * Toggle labels. `{count}` is substituted rather than concatenated, so a translation
41
+ * can put the number wherever its grammar needs it.
42
+ */
43
+ moreLabel: {
44
+ type: String,
45
+ default: '+{count} more',
46
+ },
47
+ fewerLabel: {
48
+ type: String,
49
+ default: 'Show fewer',
50
+ },
51
+ /**
52
+ * The toggle's accessible name. "+4 more" on its own says nothing about what there
53
+ * are four more of, and that visible text is otherwise the whole name.
54
+ *
55
+ * Each one extends its visible label rather than replacing it, which WCAG 2.5.3
56
+ * Label in Name requires: a name of "Show all 8 categories" over a button reading
57
+ * "+4 more" gives a speech-input user nothing to say.
58
+ */
59
+ expandLabel: {
60
+ type: String,
61
+ default: '+{count} more categories',
62
+ },
63
+ collapseLabel: {
64
+ type: String,
65
+ default: 'Show fewer categories',
66
+ },
67
+ })
68
+
69
+ const emit = defineEmits(['toggle'])
70
+
71
+ const isExpanded = ref(false)
72
+
73
+ /**
74
+ * Strings and `{ label, href }` both arrive here as the same shape, so the template has
75
+ * one branch rather than two.
76
+ *
77
+ * Labels are coerced and trimmed rather than dereferenced: `default: () => []` only
78
+ * applies to `undefined`, and Vue skips type checking entirely for a `null` on a
79
+ * non-required prop, so a null label arrives with no warning and `.trim()` on it would
80
+ * throw out of setup and take the parent subtree down.
81
+ *
82
+ * Empty labels are dropped. An empty badge is a rendering artefact — a grey pill with
83
+ * nothing in it — and it would also count against the cap, pushing a real category into
84
+ * the hidden remainder.
85
+ */
86
+ const categories = computed(() => {
87
+ // Normalise both accepted item shapes, then drop the ones with nothing to render —
88
+ // done in one chain so an empty label cannot survive as a blank badge.
89
+ return props.items
90
+ .map(item => {
91
+ const isObject = item !== null && typeof item === 'object'
92
+ const rawLabel = isObject ? item.label : item
93
+ const rawHref = isObject ? item.href : ''
94
+ const href = String(rawHref ?? '').trim()
95
+
96
+ return {
97
+ label: String(rawLabel ?? '').trim(),
98
+ // A refused href degrades to a label rather than to nothing. The category
99
+ // is still a true fact about the record; only its link was unusable.
100
+ href: isSafeHref(href) ? href : '',
101
+ }
102
+ })
103
+ .filter(category => category.label !== '')
104
+ })
105
+
106
+ const isCapped = computed(() => props.cap > 0 && categories.value.length > props.cap)
107
+
108
+ const visibleCategories = computed(() => {
109
+ if (!isCapped.value) return categories.value
110
+ if (isExpanded.value) return categories.value
111
+
112
+ return categories.value.slice(0, props.cap)
113
+ })
114
+
115
+ const hiddenCount = computed(() => categories.value.length - props.cap)
116
+
117
+ function withCount(template, count) {
118
+ return String(template ?? '').replace('{count}', count)
119
+ }
120
+
121
+ const toggleText = computed(() => {
122
+ if (isExpanded.value) return props.fewerLabel
123
+
124
+ return withCount(props.moreLabel, hiddenCount.value)
125
+ })
126
+
127
+ /**
128
+ * A disclosure, not a one-way reveal. Removing the toggle on expand would drop keyboard
129
+ * focus to the body mid-interaction, and `aria-expanded` needs something to describe.
130
+ * The visible resting state is still just "+4 more".
131
+ */
132
+ const toggleAccessibleName = computed(() => {
133
+ if (isExpanded.value) return withCount(props.collapseLabel, props.cap)
134
+
135
+ return withCount(props.expandLabel, hiddenCount.value)
136
+ })
137
+
138
+ function toggle() {
139
+ isExpanded.value = !isExpanded.value
140
+
141
+ emit('toggle', isExpanded.value)
142
+ }
143
+ </script>
144
+ <template>
145
+ <div v-if="categories.length > 0" class="rsui-category-list">
146
+ <ul class="rsui-category-list__items" :aria-label="label">
147
+ <li v-for="(category, index) in visibleCategories"
148
+ :key="index"
149
+ class="rsui-category-list__item"
150
+ >
151
+ <a v-if="category.href !== ''"
152
+ class="rsui-category-list__link"
153
+ :href="category.href"
154
+ >
155
+ <Badge neutral>{{ category.label }}</Badge>
156
+ </a>
157
+ <Badge v-if="category.href === ''" neutral>{{ category.label }}</Badge>
158
+ </li>
159
+ </ul>
160
+
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>
176
+ </div>
177
+ </template>
@@ -0,0 +1,5 @@
1
+ import CategoryList from './CategoryList.vue'
2
+
3
+ export {
4
+ CategoryList,
5
+ }
@@ -73,7 +73,12 @@ const clampClasses = {
73
73
  6: 'rsui-body-text--line-clamp-6',
74
74
  }
75
75
 
76
+ // Only ONE of these applies, and the first match in this order wins — so
77
+ // `onColor` leads. It marks a dark ground, and a muted ink passed alongside it
78
+ // (`tertiary onColor` for a date under a body paragraph) is a light-ground grey
79
+ // that all but disappears there; white is the readable reading of that pair.
76
80
  const colorClasses = {
81
+ onColor: 'rsui-body-text--on-color',
77
82
  secondary: 'rsui-body-text--secondary',
78
83
  tertiary: 'rsui-body-text--tertiary',
79
84
  quaternary: 'rsui-body-text--quaternary',
@@ -84,7 +89,6 @@ const colorClasses = {
84
89
  error: 'rsui-body-text--error',
85
90
  info: 'rsui-body-text--info',
86
91
  ai: 'rsui-body-text--ai',
87
- onColor: 'rsui-body-text--on-color',
88
92
  }
89
93
 
90
94
  const clampClass = computed(() => clampClasses[props.lines] || null)
@@ -95,6 +95,19 @@ const pageHeaderClass = computed(() => [
95
95
  </div>
96
96
  </div>
97
97
 
98
+ <!--
99
+ Between the subtitle and the meta, and outside `__top` for the same reason the
100
+ meta row is: both are full-width rows under the title block rather than
101
+ columns beside it.
102
+
103
+ Unlike the meta it is never hidden. The meta collapses into a modal below lg
104
+ because it is a grid of label/value pairs; categories are a wrapping row of
105
+ badges, and the cap is what keeps them short on a phone.
106
+ -->
107
+ <div v-if="$slots['categories']" class="rsui-page-header__categories">
108
+ <slot name="categories"></slot>
109
+ </div>
110
+
98
111
  <div v-if="$slots['meta']" class="rsui-page-header__meta">
99
112
  <slot name="meta"></slot>
100
113
  </div>
@@ -5,7 +5,9 @@ const props = defineProps({
5
5
  variant: {
6
6
  type: String,
7
7
  default: 'primary',
8
- validator: (value) => ['primary', 'secondary', 'brand', 'success', 'info', 'warning', 'error', 'ai', 'classic'].includes(value)
8
+ // `ai` is the pale animated wash; `ai-bold` is the same family at full
9
+ // strength — the dark plum surface Card's `aiBold` uses.
10
+ validator: (value) => ['primary', 'secondary', 'brand', 'success', 'info', 'warning', 'error', 'ai', 'ai-bold', 'classic'].includes(value)
9
11
  },
10
12
  bordered: {
11
13
  type: Boolean,
@@ -59,6 +61,7 @@ const variantClass = computed(() => [
59
61
  'rsui-section--warning': props.variant === 'warning',
60
62
  'rsui-section--error': props.variant === 'error',
61
63
  'rsui-section--ai': props.variant === 'ai',
64
+ 'rsui-section--ai-bold': props.variant === 'ai-bold',
62
65
  'rsui-section--classic': props.variant === 'classic',
63
66
  },
64
67
  ])
@@ -1,5 +1,6 @@
1
1
  <script setup>
2
2
  import { computed, watchEffect, useSlots, Comment, Fragment } from 'vue'
3
+ import { isSafeHref } from '../../helpers/href'
3
4
 
4
5
  const props = defineProps({
5
6
  showContent: {
@@ -42,44 +43,16 @@ const viewAllLabelText = computed(() => String(props.viewAllLabel ?? '').trim())
42
43
  const rawViewAllHref = computed(() => String(props.viewAllHref ?? '').trim())
43
44
 
44
45
  /**
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.
46
+ * `javascript:` and `data:` in an href are script execution, and this is a
47
+ * declared prop on a design-system component rather than an attribute
48
+ * passthrough — a consuming app can reasonably expect the anchor it did not
49
+ * write to be safe. Anything unrecognised is refused loudly, since a link that
50
+ * quietly stops working is the failure this component exists to avoid.
51
+ *
52
+ * The rule itself lives in `helpers/href`, because CategoryList builds an anchor
53
+ * out of a declared prop the same way and two copies of a safety check drift.
67
54
  */
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
- })
55
+ const isViewAllHrefSafe = computed(() => isSafeHref(rawViewAllHref.value))
83
56
 
84
57
  const hasViewAllLabel = computed(() => viewAllLabelText.value !== '')
85
58
 
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Whether a URL is safe to put in an `href` this library renders itself.
3
+ *
4
+ * Internal on purpose — not re-exported from `helpers/index.js`. It is a guard for
5
+ * components that build an anchor out of a declared prop, not a public utility, and
6
+ * consuming apps have their own escaping rules.
7
+ *
8
+ * The rule is the one SectionFooter's view-all link established: a consuming app can
9
+ * reasonably expect an anchor it did not write to be safe, so `javascript:` and
10
+ * `data:` — which are script execution, not navigation — are refused, and so is
11
+ * anything else with an unrecognised scheme.
12
+ */
13
+
14
+ /** Schemes an anchor may safely carry. */
15
+ const SAFE_HREF_SCHEME = /^(https?:|mailto:|tel:)/i
16
+
17
+ /**
18
+ * `//evil.com` is protocol-relative: it starts with a slash but navigates to another
19
+ * host entirely, so a leading-slash test alone would wave it through.
20
+ */
21
+ const PROTOCOL_RELATIVE = /^\/\//
22
+
23
+ /**
24
+ * Anything with a scheme has one before the first slash. No colon in that span means a
25
+ * relative path — `courses/1`, `./x`, `../x`, `#anchor`, `?q=1` — which cannot leave the
26
+ * site and so needs no allow-list of its own.
27
+ */
28
+ function isRelativeHref(href) {
29
+ const beforeFirstSlash = href.split('/')[0]
30
+
31
+ return !beforeFirstSlash.includes(':')
32
+ }
33
+
34
+ /**
35
+ * An empty href counts as safe: it means "no link was asked for", which callers handle
36
+ * by rendering no anchor at all rather than by rejecting it.
37
+ */
38
+ export function isSafeHref(href) {
39
+ const trimmed = String(href ?? '').trim()
40
+
41
+ if (trimmed === '') return true
42
+ if (PROTOCOL_RELATIVE.test(trimmed)) return false
43
+ if (SAFE_HREF_SCHEME.test(trimmed)) return true
44
+
45
+ return isRelativeHref(trimmed)
46
+ }