@redseed/redseed-ui-vue3 10.0.0 → 10.3.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.
@@ -15,6 +15,21 @@ const props = defineProps({
15
15
  type: Boolean,
16
16
  default: false,
17
17
  },
18
+ /**
19
+ * Set by a field component to say "the id I handed you is already on my own control".
20
+ *
21
+ * It is what lets `useFormFieldA11y()` tell two identical-looking nestings apart. A
22
+ * field renders this slot as its own root and puts the id on its own input, so anything
23
+ * appearing in the slots below is a SEPARATE control and must mint its own id. A bare
24
+ * FormFieldSlot written by a consumer owns no control, so whatever it wraps is the
25
+ * control this label belongs to and should adopt the id.
26
+ *
27
+ * Injection alone cannot distinguish those — both cases simply see a provider above.
28
+ */
29
+ idClaimed: {
30
+ type: Boolean,
31
+ default: false,
32
+ },
18
33
  })
19
34
 
20
35
  defineOptions({
@@ -39,11 +54,13 @@ const ariaDescribedby = computed(() => {
39
54
  // aria-invalid when error slot is present
40
55
  const ariaInvalid = computed(() => slots.error ? true : undefined)
41
56
 
42
- // Provide a11y values for child components to inject
57
+ // Provide a11y values for child components to inject. `idClaimed` travels with them so a
58
+ // nested field can tell whether the id above it is already spoken for.
43
59
  provide(FormFieldA11yKey, {
44
60
  inputId,
45
61
  ariaDescribedby,
46
62
  ariaInvalid,
63
+ idClaimed: computed(() => props.idClaimed),
47
64
  })
48
65
 
49
66
  const formFieldSlotClass = computed(() => [
@@ -23,7 +23,8 @@ defineExpose({
23
23
  </script>
24
24
  <template>
25
25
  <FormFieldSlot
26
- :id="$attrs.id"
26
+ :id="inputId"
27
+ id-claimed
27
28
  :class="[$attrs.class, 'rsui-form-field-text']"
28
29
  :required="$attrs.required"
29
30
  :showAsterisk="$attrs.showAsterisk"
@@ -47,7 +48,7 @@ defineExpose({
47
48
  :autocomplete="$attrs.autocomplete"
48
49
  :autofocus="$attrs.autofocus"
49
50
  :disabled="$attrs.disabled"
50
- :id="inputId || $attrs.id"
51
+ :id="inputId"
51
52
  :inputmode="$attrs.inputmode"
52
53
  :max="$attrs.max"
53
54
  :maxlength="$attrs.maxlength"
@@ -50,7 +50,8 @@ defineExpose({
50
50
  </script>
51
51
  <template>
52
52
  <FormFieldSlot
53
- :id="$attrs.id"
53
+ :id="inputId"
54
+ id-claimed
54
55
  :class="[$attrs.class, 'rsui-form-field-textarea']"
55
56
  :required="$attrs.required"
56
57
  :showAsterisk="$attrs.showAsterisk"
@@ -69,7 +70,7 @@ defineExpose({
69
70
  :autocomplete="$attrs.autocomplete"
70
71
  :autofocus="$attrs.autofocus"
71
72
  :disabled="$attrs.disabled"
72
- :id="inputId || $attrs.id"
73
+ :id="inputId"
73
74
  :maxlength="$attrs.maxlength"
74
75
  :minlength="$attrs.minlength"
75
76
  :name="$attrs.name"
@@ -1,5 +1,5 @@
1
1
  <script setup>
2
- import { ref, computed, useAttrs } from 'vue'
2
+ import { ref, computed } from 'vue'
3
3
  import { useFuse } from '@vueuse/integrations/useFuse'
4
4
  import FormFieldSlot from './FormFieldSlot.vue'
5
5
  import FormFieldSearch from './FormFieldSearch.vue'
@@ -49,7 +49,6 @@ const props = defineProps({
49
49
 
50
50
  const emit = defineEmits(['change'])
51
51
 
52
- const attrs = useAttrs()
53
52
  const { inputId, ariaDescribedby, ariaInvalid } = useFormFieldA11y()
54
53
 
55
54
  const model = defineModel({
@@ -57,8 +56,6 @@ const model = defineModel({
57
56
  validator: () => true,
58
57
  })
59
58
 
60
- const effectiveId = computed(() => inputId.value || attrs.id)
61
-
62
59
  const show = ref(false)
63
60
  function openModal() {
64
61
  if (props.disabled) return
@@ -181,7 +178,8 @@ function deselectOption(node) {
181
178
 
182
179
  <template>
183
180
  <FormFieldSlot
184
- :id="$attrs.id"
181
+ :id="inputId"
182
+ id-claimed
185
183
  :class="[$attrs.class, 'rsui-form-field-tree-select']"
186
184
  :required="$attrs.required"
187
185
  :showAsterisk="$attrs.showAsterisk"
@@ -199,7 +197,7 @@ function deselectOption(node) {
199
197
  { 'rsui-form-field-tree-select__trigger--full': fullWidth },
200
198
  { 'rsui-form-field-tree-select__trigger--disabled': disabled },
201
199
  ]"
202
- :id="effectiveId"
200
+ :id="inputId"
203
201
  :aria-describedby="ariaDescribedby"
204
202
  :aria-invalid="ariaInvalid"
205
203
  :aria-required="$attrs.required || undefined"
@@ -222,7 +220,14 @@ function deselectOption(node) {
222
220
  </template>
223
221
 
224
222
  <div class="rsui-form-field-tree-select__search">
223
+ <!--
224
+ Its own id. This search box lives inside the tree select's own
225
+ FormFieldSlot, so without one it would adopt the outer field's id and two
226
+ controls would answer to it — the trigger and this input. `id-claimed`
227
+ above makes it mint its own, and naming it explicitly keeps it legible.
228
+ -->
225
229
  <FormFieldSearch
230
+ :id="`${inputId}-search`"
226
231
  v-model="searchInput"
227
232
  :placeholder="searchPlaceholder"
228
233
  ></FormFieldSearch>
@@ -46,6 +46,26 @@ const props = defineProps({
46
46
  type: Boolean,
47
47
  default: false,
48
48
  },
49
+ /*
50
+ * The heading level of the title.
51
+ *
52
+ * Defaults to 1, which is right for a real page and was the hardcoded behaviour.
53
+ * It is wrong everywhere else, and there was no way to say so: a PageHeader in a
54
+ * drawer, a preview or a docs page emitted a second <h1> that no care in the
55
+ * surrounding markup could correct. Any page that demonstrates the component more than
56
+ * once emits one <h1> per instance — the Storybook autodocs page for PageHeader
57
+ * renders 21 stories, so 21 of them on one page.
58
+ *
59
+ * Same shape and validator as SectionHeader.headingLevel, deliberately — the two
60
+ * header components disagreeing about whether their level is the consumer's business
61
+ * is what #337 started closing and this finishes. `null` renders a <span>, for a
62
+ * title that is styled as one but is not a heading at all.
63
+ */
64
+ headingLevel: {
65
+ type: [Number, String],
66
+ default: 1,
67
+ validator: (value) => value === null || ['1', '2', '3', '4', '5', '6'].includes(String(value)),
68
+ },
49
69
  })
50
70
 
51
71
  /**
@@ -108,6 +128,28 @@ onBeforeUnmount(() => {
108
128
  metaObserver = null
109
129
  })
110
130
 
131
+ /*
132
+ * Shared with the tag below but NOT with the validator in defineProps, which is forced to
133
+ * repeat the list: defineProps() is hoisted outside setup() and cannot close over a local.
134
+ * A test asserts the two agree.
135
+ */
136
+ const HEADING_LEVELS = ['1', '2', '3', '4', '5', '6']
137
+
138
+ /*
139
+ * Falls back to a span for anything that is not a real heading level, rather than
140
+ * interpolating whatever it was handed.
141
+ *
142
+ * The validator only warns, and Vue strips validators from production builds — so before
143
+ * this, `heading-level="null"` (the string, an easy slip when the correct form needs a
144
+ * binding) rendered <hnull>, and `:heading-level="7"` rendered <h7>. Both are invalid
145
+ * elements, both shipped silently, and both LOOKED correct because the styling hook is a
146
+ * class rather than the tag. Rendered and verified, not assumed.
147
+ *
148
+ * A span is the safe direction: it never corrupts the document outline, where <h7> is
149
+ * simply broken markup.
150
+ */
151
+ const titleTag = computed(() => HEADING_LEVELS.includes(String(props.headingLevel)) ? `h${props.headingLevel}` : 'span')
152
+
111
153
  const pageHeaderClass = computed(() => [
112
154
  'rsui-page-header',
113
155
  {
@@ -143,9 +185,9 @@ const pageHeaderClass = computed(() => [
143
185
  </div>
144
186
 
145
187
  <div class="rsui-page-header__title-text-container">
146
- <h1>
188
+ <component :is="titleTag" class="rsui-page-header__heading">
147
189
  <slot name="title"></slot>
148
- </h1>
190
+ </component>
149
191
  <div v-if="$slots.status" class="rsui-page-header__status">
150
192
  <slot name="status"></slot>
151
193
  </div>
@@ -85,5 +85,17 @@ const emit = defineEmits(['click'])
85
85
  <div class="rsui-meta-info__value">
86
86
  <slot></slot>
87
87
  </div>
88
+
89
+ <!--
90
+ A second, subordinate line UNDER the value — "Repeats weekly on Tuesday" beneath
91
+ a date, rather than a fourth field beside it.
92
+
93
+ A sibling of __value rather than a child of it: __value is a flex ROW (it lines an
94
+ avatar up beside its text), so a subtitle dropped inside it lands next to the value
95
+ instead of below. The root is the column, and its gap-y already spaces this.
96
+ -->
97
+ <div v-if="$slots.subtitle" class="rsui-meta-info__subtitle">
98
+ <slot name="subtitle"></slot>
99
+ </div>
88
100
  </div>
89
101
  </template>
@@ -139,7 +139,27 @@ function showsToolbar() {
139
139
  * Element used to render the title — a real heading when a level is supplied,
140
140
  * otherwise a non-heading <span>, which now requires passing null explicitly.
141
141
  */
142
- const titleTag = computed(() => props.headingLevel ? `h${props.headingLevel}` : 'span')
142
+ /*
143
+ * Shared with the tag below but NOT with the validator in defineProps, which is forced to
144
+ * repeat the list: defineProps() is hoisted outside setup() and cannot close over a local.
145
+ * A test asserts the two agree.
146
+ */
147
+ const HEADING_LEVELS = ['1', '2', '3', '4', '5', '6']
148
+
149
+ /*
150
+ * Falls back to a span for anything that is not a real heading level, rather than
151
+ * interpolating whatever it was handed.
152
+ *
153
+ * The validator only warns, and Vue strips validators from production builds — so before
154
+ * this, `heading-level="null"` (the string, an easy slip when the correct form needs a
155
+ * binding) rendered <hnull>, and `:heading-level="7"` rendered <h7>. Both are invalid
156
+ * elements, both shipped silently, and both LOOKED correct because the styling hook is a
157
+ * class rather than the tag. Rendered and verified, not assumed.
158
+ *
159
+ * A span is the safe direction: it never corrupts the document outline, where <h7> is
160
+ * simply broken markup.
161
+ */
162
+ const titleTag = computed(() => HEADING_LEVELS.includes(String(props.headingLevel)) ? `h${props.headingLevel}` : 'span')
143
163
  </script>
144
164
  <template>
145
165
  <div ref="sectionHeaderElement"
@@ -5,6 +5,7 @@ import Tr from './Tr.vue'
5
5
  import Th from './Th.vue'
6
6
  import Td from './Td.vue'
7
7
  import ColumnPicker from './ColumnPicker.vue'
8
+ import FormFieldCheckbox from '../FormField/FormFieldCheckbox.vue'
8
9
 
9
10
  const titleId = _.uniqueId('table-title-')
10
11
 
@@ -58,8 +59,96 @@ const props = defineProps({
58
59
  type: Boolean,
59
60
  default: false,
60
61
  },
62
+ /**
63
+ * Row selection: a checkbox column, and a select-all in the header.
64
+ *
65
+ * Opt-in, so no existing table changes. Use with `v-model:selected`, which holds the
66
+ * ids of the selected rows.
67
+ *
68
+ * Exists because the alternative was consumers hand-rolling it, and the LMS shows what
69
+ * that costs: bare `<input type="checkbox">` elements with the select-all reconciled
70
+ * through `document.getElementById`, no indeterminate state, no accessible names, and
71
+ * the select-all stranded in the `#title` slot because there was no header cell to put
72
+ * it in — which is why it landed on its own line on a phone (#390).
73
+ */
74
+ selectable: {
75
+ type: Boolean,
76
+ default: false,
77
+ },
78
+ /**
79
+ * Accessible name for the select-all checkbox.
80
+ *
81
+ * A prop rather than a slot because it is an accessible name, not content — there is
82
+ * nothing to render, and a slot would invite markup into a `<th>` that holds only a
83
+ * control.
84
+ */
85
+ selectAllLabel: {
86
+ type: String,
87
+ default: 'Select all rows',
88
+ },
89
+ })
90
+
91
+ /*
92
+ * v-model:selected — the ids of the selected rows.
93
+ *
94
+ * The consumer owns the array, which is the whole point: the LMS version kept the truth in
95
+ * the DOM and wrote `.checked` directly, so two tables on one page collided on the id and
96
+ * nothing could read the selection back out.
97
+ */
98
+ const selected = defineModel('selected', {
99
+ type: Array,
100
+ default: () => [],
61
101
  })
62
102
 
103
+ const selectableRowIds = computed(() => props.rows.map((row) => row.id))
104
+
105
+ const isRowSelected = (row) => selected.value.includes(row.id)
106
+
107
+ /*
108
+ * Every row, none, or some. The third is the state a select-all exists to report and the
109
+ * one the hand-rolled version could not: with a plain boolean, a partial selection renders
110
+ * identically to an empty one.
111
+ */
112
+ const allSelected = computed(() =>
113
+ selectableRowIds.value.length > 0
114
+ && selectableRowIds.value.every((id) => selected.value.includes(id))
115
+ )
116
+
117
+ const someSelected = computed(() =>
118
+ selectableRowIds.value.some((id) => selected.value.includes(id))
119
+ )
120
+
121
+ function toggleRow(row) {
122
+ // Rebuilt rather than mutated, so a consumer holding the array in a readonly store or
123
+ // watching it shallowly still sees the change.
124
+ selected.value = isRowSelected(row)
125
+ ? selected.value.filter((id) => id !== row.id)
126
+ : [...selected.value, row.id]
127
+ }
128
+
129
+ /*
130
+ * Select-all acts on THESE rows, not on everything the consumer might have.
131
+ *
132
+ * A paginated or filtered table only knows the page it was handed, so "all" can only
133
+ * honestly mean "all of these". Clearing removes just this page's ids and leaves any
134
+ * selection made on another page intact.
135
+ */
136
+ function toggleAll() {
137
+ selected.value = allSelected.value
138
+ ? selected.value.filter((id) => !selectableRowIds.value.includes(id))
139
+ : [...new Set([...selected.value, ...selectableRowIds.value])]
140
+ }
141
+
142
+ /*
143
+ * Names the row, not the control. A column of boxes all called "Select row" is a column of
144
+ * identical announcements with nothing to tell them apart, which is what the LMS shipped.
145
+ * `row.selectionLabel` wins; `row.ariaLabel` is the fallback because rows already carry it
146
+ * for the clickable case, so most consumers get a real name for free.
147
+ */
148
+ function rowSelectionLabel(row) {
149
+ return row.selectionLabel ?? row.ariaLabel ?? 'Select row'
150
+ }
151
+
63
152
  // v-model:visibleKeys — undefined means "uncontrolled" / use internal default (all visible).
64
153
  const visibleKeys = defineModel('visibleKeys', {
65
154
  type: Array,
@@ -242,6 +331,29 @@ watch([() => props.rows, visibleColumns], () => nextTick(updateScrollable), { de
242
331
  <caption v-if="!showHeader && $slots.title"><slot name="title"></slot></caption>
243
332
  <thead v-if="visibleColumns.length">
244
333
  <Tr>
334
+ <!--
335
+ A real header cell for the select-all, which is the point.
336
+ It used to have nowhere to go but the #title slot, and
337
+ CardHeader stacks title and actions on separate rows when
338
+ narrow — so on a phone it was stranded on a line of its own
339
+ above the search (#390).
340
+
341
+ scope="col" like any other header cell; the checkbox carries
342
+ the accessible name, so the cell needs no text of its own.
343
+ -->
344
+ <Th v-if="selectable"
345
+ scope="col"
346
+ :fixed="fixedColumns"
347
+ class="rsui-table__select-cell"
348
+ >
349
+ <FormFieldCheckbox sm
350
+ :model-value="allSelected"
351
+ :indeterminate="someSelected && !allSelected"
352
+ :aria-label="selectAllLabel"
353
+ @update:model-value="toggleAll"
354
+ ></FormFieldCheckbox>
355
+ </Th>
356
+
245
357
  <Th v-for="column in visibleColumns"
246
358
  :key="column.key"
247
359
  scope="col"
@@ -268,6 +380,24 @@ watch([() => props.rows, visibleColumns], () => nextTick(updateScrollable), { de
268
380
  :aria-label="row.ariaLabel"
269
381
  @click="$emit('click:row', row)"
270
382
  >
383
+ <!--
384
+ @click.stop so ticking a box on a clickable row does not also
385
+ open the row. Selecting and navigating are different intents,
386
+ and the whole reason to select is to act on rows without
387
+ visiting them.
388
+ -->
389
+ <Td v-if="selectable"
390
+ :fixed="fixedColumns"
391
+ class="rsui-table__select-cell"
392
+ @click.stop
393
+ >
394
+ <FormFieldCheckbox sm
395
+ :model-value="isRowSelected(row)"
396
+ :aria-label="rowSelectionLabel(row)"
397
+ @update:model-value="toggleRow(row)"
398
+ ></FormFieldCheckbox>
399
+ </Td>
400
+
271
401
  <Td v-for="column in visibleColumns"
272
402
  :key="column.key"
273
403
  :alignment="column?.alignment"