@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.
- package/index.js +1 -0
- package/package.json +1 -1
- package/src/components/Button/ButtonSlot.vue +86 -4
- package/src/components/DropdownMenu/DropdownDivider.vue +22 -0
- package/src/components/DropdownMenu/DropdownOption.vue +25 -1
- package/src/components/DropdownMenu/index.js +3 -1
- package/src/components/FormField/FormFieldCheckbox.vue +58 -5
- package/src/components/FormField/FormFieldCombobox.vue +11 -16
- package/src/components/FormField/FormFieldRadioGroup.vue +14 -12
- package/src/components/FormField/FormFieldSearchAsync.vue +9 -11
- package/src/components/FormField/FormFieldSelect.vue +10 -18
- package/src/components/FormField/FormFieldSlot.vue +18 -1
- package/src/components/FormField/FormFieldText.vue +3 -2
- package/src/components/FormField/FormFieldTextarea.vue +3 -2
- package/src/components/FormField/FormFieldTreeSelect.vue +11 -6
- package/src/components/Layout/PageHeader.vue +44 -2
- package/src/components/MetaInfo/MetaInfo.vue +12 -0
- package/src/components/Section/SectionHeader.vue +21 -1
- package/src/components/Table/Table.vue +130 -0
- package/src/components/Toast/Toast.vue +388 -0
- package/src/components/Toast/index.js +5 -0
- package/src/composables/useFormFieldA11y.js +72 -15
- package/src/helpers/slots.js +5 -2
|
@@ -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="
|
|
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
|
|
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="
|
|
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
|
|
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
|
|
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="
|
|
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="
|
|
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
|
-
<
|
|
188
|
+
<component :is="titleTag" class="rsui-page-header__heading">
|
|
147
189
|
<slot name="title"></slot>
|
|
148
|
-
</
|
|
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
|
-
|
|
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"
|