picocrank 1.31.1 → 1.33.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/docs/table.md +99 -0
- package/package.json +1 -1
- package/router.js +11 -0
- package/styles/picocrank-extensions.css +65 -0
- package/vue/components/Calendar.vue +41 -63
- package/vue/components/LoadingArea.spec.js +72 -0
- package/vue/components/LoadingArea.vue +176 -0
- package/vue/components/Table.spec.js +715 -0
- package/vue/components/Table.vue +974 -16
- package/vue/components/TableColumnFilterPopover.vue +56 -8
- package/vue/components/TableRowContextMenu.vue +267 -0
- package/vue/composables/tableFilters.js +6 -1
- package/vue/data/navSectionHubs.js +5 -0
- package/vue/examples/LoadingAreaExample.vue +175 -0
- package/vue/examples/TableExample.vue +156 -4
package/docs/table.md
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# Table
|
|
2
|
+
|
|
3
|
+
`Table` supports local data and remote `fetchRows` data. Existing usages remain
|
|
4
|
+
uncontrolled; pass any query-state prop to control that field independently.
|
|
5
|
+
|
|
6
|
+
## Query state
|
|
7
|
+
|
|
8
|
+
```vue
|
|
9
|
+
<Table
|
|
10
|
+
v-model:sort-by="sortBy"
|
|
11
|
+
v-model:sort-dir="sortDir"
|
|
12
|
+
v-model:page="page"
|
|
13
|
+
v-model:page-size="pageSize"
|
|
14
|
+
/>
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
`sortBy`, `sortDir`, `page`, and `pageSize` default to internal state when
|
|
18
|
+
omitted. Controlled values may be changed after mount. User actions emit the
|
|
19
|
+
corresponding `update:*` event. Sorting resets the page by default; set
|
|
20
|
+
`:reset-page-on-sort="false"` to preserve the current page. Page-size, data, and
|
|
21
|
+
filter changes continue to reset to page 1.
|
|
22
|
+
|
|
23
|
+
A sortable header can provide a value comparator:
|
|
24
|
+
|
|
25
|
+
```js
|
|
26
|
+
{
|
|
27
|
+
key: 'version',
|
|
28
|
+
sortable: true,
|
|
29
|
+
comparator: (left, right, leftRow, rightRow) =>
|
|
30
|
+
left.localeCompare(right, undefined, { numeric: true }),
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The comparator defines ascending order and receives non-null cell values plus
|
|
35
|
+
their rows. `Table` reverses its result for descending order and keeps null
|
|
36
|
+
values last. `sortRows(rows, sortBy, sortDir, comparator)` accepts the same
|
|
37
|
+
optional comparator.
|
|
38
|
+
|
|
39
|
+
## Cells and display hooks
|
|
40
|
+
|
|
41
|
+
Named `#cell-{key}` slots and the generic `#cell` slot receive:
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
{ row, value, header, rowIndex, sourceIndex }
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`rowIndex` is the index on the displayed page. For local data, `sourceIndex` is
|
|
48
|
+
the index in `data`; for remote data it is the index in the current response.
|
|
49
|
+
|
|
50
|
+
The `rowClass` and `rowStyle` callbacks receive
|
|
51
|
+
`{ row, rowIndex, sourceIndex }`. `cellClass`, `cellStyle`, and `cellAttrs`
|
|
52
|
+
receive that context plus `{ value, header }`. Return Vue-compatible class or
|
|
53
|
+
style values. `cellAttrs` is intended for attributes such as `title`, `aria-*`,
|
|
54
|
+
and `data-*`; use the dedicated class/style callbacks for those fields.
|
|
55
|
+
Header `class` continues to apply to both its header and body cells.
|
|
56
|
+
|
|
57
|
+
## Selection and active cells
|
|
58
|
+
|
|
59
|
+
`selectionClickMode` controls selectable-row clicks:
|
|
60
|
+
|
|
61
|
+
- `row` (default): row clicks toggle selection; Ctrl/Cmd also toggles and Shift
|
|
62
|
+
selects a range on the displayed page.
|
|
63
|
+
- `checkbox`: only checkboxes change selection; row clicks emit `row-click`.
|
|
64
|
+
- `modifier`: Ctrl/Cmd toggles, Shift selects a displayed-page range, and an
|
|
65
|
+
unmodified click emits `row-click`.
|
|
66
|
+
- `extended`: unmodified clicks replace the selection, Ctrl/Cmd toggles, and
|
|
67
|
+
Shift adds a range on the displayed page. This matches desktop-style
|
|
68
|
+
selection while leaving the default behavior unchanged.
|
|
69
|
+
|
|
70
|
+
The additive `row-click` payload is `{ row, index, event }`, where `event` is
|
|
71
|
+
the native click event.
|
|
72
|
+
|
|
73
|
+
`activeCell` is an optional controlled `{ rowKey, columnKey }` value:
|
|
74
|
+
|
|
75
|
+
```vue
|
|
76
|
+
<Table
|
|
77
|
+
v-model:active-cell="activeCell"
|
|
78
|
+
@cell-click="onCellClick"
|
|
79
|
+
/>
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Clicking a data cell emits `cell-click` with the enriched cell context and adds
|
|
83
|
+
the visibly outlined `table-active-cell` class. The component ref exposes:
|
|
84
|
+
|
|
85
|
+
- `clearFilters()`
|
|
86
|
+
- `focusActiveCell(options?)` (returns whether a displayed active cell was focused)
|
|
87
|
+
- `clearActiveCell()`
|
|
88
|
+
- `activateCellAt(rowIndex, columnIndex, options?)`
|
|
89
|
+
- `moveActiveCell(rowDelta, columnDelta, options?)`
|
|
90
|
+
- `getActiveCellContext()`
|
|
91
|
+
- the existing `loadDeveloperDefaults()` and `layoutLabel`
|
|
92
|
+
|
|
93
|
+
Cell indices use `displayRows` and `visibleHeaders` and are clamped to the
|
|
94
|
+
displayed grid. Pass `{ wrapColumns: true }` to `moveActiveCell` to move from
|
|
95
|
+
the last column of one row to the first column of the next (or in reverse),
|
|
96
|
+
which is suitable for Tab/Shift+Tab. Pass `{ focus: true, focusOptions? }` to
|
|
97
|
+
activation or movement methods to focus the destination after Vue renders.
|
|
98
|
+
`getActiveCellContext()` returns the cell slot context plus `rowKey`,
|
|
99
|
+
`columnKey`, and `columnIndex`, or `null` when the active cell is not displayed.
|
package/package.json
CHANGED
package/router.js
CHANGED
|
@@ -15,6 +15,7 @@ import { StatusIcon } from '@hugeicons/core-free-icons';
|
|
|
15
15
|
import { PaintBoardIcon } from '@hugeicons/core-free-icons';
|
|
16
16
|
import { UserIcon } from '@hugeicons/core-free-icons';
|
|
17
17
|
import { InformationCircleIcon } from '@hugeicons/core-free-icons';
|
|
18
|
+
import { Loading03Icon } from '@hugeicons/core-free-icons';
|
|
18
19
|
|
|
19
20
|
import {
|
|
20
21
|
hubChildBreadcrumbs,
|
|
@@ -176,6 +177,16 @@ const routes = [
|
|
|
176
177
|
breadcrumbs: hubChildBreadcrumbs('NavFeedbackStatusHub', 'DialogExample', 'Dialog'),
|
|
177
178
|
}
|
|
178
179
|
},
|
|
180
|
+
{
|
|
181
|
+
name: 'LoadingAreaExample',
|
|
182
|
+
path: '/loading-area-example',
|
|
183
|
+
component: () => import('./vue/examples/LoadingAreaExample.vue'),
|
|
184
|
+
meta: {
|
|
185
|
+
title: 'Loading area',
|
|
186
|
+
icon: Loading03Icon,
|
|
187
|
+
breadcrumbs: hubChildBreadcrumbs('NavFeedbackStatusHub', 'LoadingAreaExample', 'Loading area'),
|
|
188
|
+
}
|
|
189
|
+
},
|
|
179
190
|
{
|
|
180
191
|
name: 'TabsExample',
|
|
181
192
|
path: '/tabs-example',
|
|
@@ -252,3 +252,68 @@ header.fixed ~ #layout > #content,
|
|
|
252
252
|
@container responsive-cols (max-width: 22rem) {
|
|
253
253
|
:is(th, td).col-priority-5 { display: none; }
|
|
254
254
|
}
|
|
255
|
+
|
|
256
|
+
/*
|
|
257
|
+
* Floating context menu: reuse Femtocrank nav.menu dropdown chrome, forced open
|
|
258
|
+
* for cursor-positioned / teleported menus (table rows, calendar days, etc.).
|
|
259
|
+
*/
|
|
260
|
+
nav.menu.context-menu {
|
|
261
|
+
position: fixed;
|
|
262
|
+
z-index: 1000;
|
|
263
|
+
margin: 0;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
nav.menu.context-menu:focus,
|
|
267
|
+
nav.menu.context-menu:focus-visible {
|
|
268
|
+
outline: none;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
nav.menu.context-menu > ul {
|
|
272
|
+
margin: 0;
|
|
273
|
+
padding: 0;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
nav.menu.context-menu > ul > li {
|
|
277
|
+
display: block;
|
|
278
|
+
margin: 0;
|
|
279
|
+
padding: 0;
|
|
280
|
+
border-radius: 0;
|
|
281
|
+
background: transparent;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
nav.menu.context-menu > ul > li:hover,
|
|
285
|
+
nav.menu.context-menu > ul > li:focus-within {
|
|
286
|
+
background: transparent;
|
|
287
|
+
color: inherit;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
nav.menu.context-menu > ul > li > div {
|
|
291
|
+
display: block;
|
|
292
|
+
position: static;
|
|
293
|
+
top: auto;
|
|
294
|
+
left: auto;
|
|
295
|
+
min-width: 12em;
|
|
296
|
+
padding: var(--menu-dropdown-padding);
|
|
297
|
+
border-radius: var(--menu-dropdown-radius);
|
|
298
|
+
background-color: var(--menu-dropdown-background-color);
|
|
299
|
+
box-shadow: var(--menu-dropdown-shadow);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
nav.menu.context-menu > ul > li > div::before {
|
|
303
|
+
content: none;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
nav.menu.context-menu > ul div ul button.inline-icon {
|
|
307
|
+
display: inline-flex;
|
|
308
|
+
align-items: center;
|
|
309
|
+
gap: 0.45em;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
nav.menu.context-menu > ul div ul li[role="separator"] {
|
|
313
|
+
height: 1px;
|
|
314
|
+
margin: 0.25em 0.35em;
|
|
315
|
+
padding: 0;
|
|
316
|
+
background: var(--menu-dropdown-border-color, var(--border-color));
|
|
317
|
+
border-radius: 0;
|
|
318
|
+
pointer-events: none;
|
|
319
|
+
}
|
|
@@ -641,7 +641,7 @@ function onDayContextMenu(date, mouseEvent) {
|
|
|
641
641
|
|
|
642
642
|
nextTick(() => {
|
|
643
643
|
positionDayContextMenu(mouseEvent.clientX, mouseEvent.clientY)
|
|
644
|
-
dayContextMenuRef.value?.
|
|
644
|
+
dayContextMenuRef.value?.focus({ preventScroll: true })
|
|
645
645
|
})
|
|
646
646
|
}
|
|
647
647
|
|
|
@@ -1067,43 +1067,53 @@ watch([viewMonth, viewYear], () => {
|
|
|
1067
1067
|
</div>
|
|
1068
1068
|
|
|
1069
1069
|
<Teleport to="body">
|
|
1070
|
-
<
|
|
1070
|
+
<nav
|
|
1071
1071
|
v-if="dayContextMenuOpen"
|
|
1072
1072
|
ref="dayContextMenuRef"
|
|
1073
|
-
class="calendar-day-context-menu"
|
|
1073
|
+
class="menu context-menu calendar-day-context-menu"
|
|
1074
1074
|
role="menu"
|
|
1075
|
+
tabindex="-1"
|
|
1075
1076
|
:style="dayContextMenuStyle"
|
|
1077
|
+
aria-label="Day view options"
|
|
1076
1078
|
@click.stop
|
|
1077
1079
|
@contextmenu.prevent
|
|
1078
1080
|
>
|
|
1079
|
-
<
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1081
|
+
<ul class="noListStyle">
|
|
1082
|
+
<li>
|
|
1083
|
+
<div>
|
|
1084
|
+
<ul>
|
|
1085
|
+
<li v-if="internalViewMode !== 'day'">
|
|
1086
|
+
<button
|
|
1087
|
+
type="button"
|
|
1088
|
+
role="menuitem"
|
|
1089
|
+
@click="showOnlyThisDay"
|
|
1090
|
+
>
|
|
1091
|
+
Show only this day
|
|
1092
|
+
</button>
|
|
1093
|
+
</li>
|
|
1094
|
+
<li v-if="internalViewMode !== 'week'">
|
|
1095
|
+
<button
|
|
1096
|
+
type="button"
|
|
1097
|
+
role="menuitem"
|
|
1098
|
+
@click="showOnlyThisWeek"
|
|
1099
|
+
>
|
|
1100
|
+
Show only this week
|
|
1101
|
+
</button>
|
|
1102
|
+
</li>
|
|
1103
|
+
<li v-if="internalViewMode !== 'month'">
|
|
1104
|
+
<button
|
|
1105
|
+
type="button"
|
|
1106
|
+
role="menuitem"
|
|
1107
|
+
@click="showFullMonth"
|
|
1108
|
+
>
|
|
1109
|
+
Show full month
|
|
1110
|
+
</button>
|
|
1111
|
+
</li>
|
|
1112
|
+
</ul>
|
|
1113
|
+
</div>
|
|
1114
|
+
</li>
|
|
1115
|
+
</ul>
|
|
1116
|
+
</nav>
|
|
1107
1117
|
</Teleport>
|
|
1108
1118
|
</div>
|
|
1109
1119
|
</template>
|
|
@@ -1522,35 +1532,3 @@ watch([viewMonth, viewYear], () => {
|
|
|
1522
1532
|
}
|
|
1523
1533
|
}
|
|
1524
1534
|
</style>
|
|
1525
|
-
|
|
1526
|
-
<style>
|
|
1527
|
-
.calendar-day-context-menu {
|
|
1528
|
-
position: fixed;
|
|
1529
|
-
z-index: 1000;
|
|
1530
|
-
min-width: 12rem;
|
|
1531
|
-
padding: 0.35rem;
|
|
1532
|
-
border: 1px solid var(--table-popover-border);
|
|
1533
|
-
border-radius: 8px;
|
|
1534
|
-
background: var(--table-popover-bg);
|
|
1535
|
-
box-shadow: var(--table-popover-shadow);
|
|
1536
|
-
}
|
|
1537
|
-
|
|
1538
|
-
.calendar-day-context-menu-item {
|
|
1539
|
-
display: block;
|
|
1540
|
-
width: 100%;
|
|
1541
|
-
padding: 0.5rem 0.75rem;
|
|
1542
|
-
border: none;
|
|
1543
|
-
border-radius: 4px;
|
|
1544
|
-
background: transparent;
|
|
1545
|
-
color: var(--table-popover-fg);
|
|
1546
|
-
font: inherit;
|
|
1547
|
-
text-align: left;
|
|
1548
|
-
cursor: pointer;
|
|
1549
|
-
}
|
|
1550
|
-
|
|
1551
|
-
.calendar-day-context-menu-item:hover,
|
|
1552
|
-
.calendar-day-context-menu-item:focus-visible {
|
|
1553
|
-
background: var(--table-popover-item-bg);
|
|
1554
|
-
outline: none;
|
|
1555
|
-
}
|
|
1556
|
-
</style>
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { mount } from '@vue/test-utils'
|
|
3
|
+
import { nextTick } from 'vue'
|
|
4
|
+
import LoadingArea from './LoadingArea.vue'
|
|
5
|
+
|
|
6
|
+
const globalStubs = {
|
|
7
|
+
HugeiconsIcon: true,
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe('LoadingArea', () => {
|
|
11
|
+
it('shows the placeholder by default', () => {
|
|
12
|
+
const wrapper = mount(LoadingArea, {
|
|
13
|
+
global: { stubs: globalStubs },
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
expect(wrapper.find('[role="status"]').exists()).toBe(true)
|
|
17
|
+
expect(wrapper.text()).toContain('Loading…')
|
|
18
|
+
expect(wrapper.attributes('aria-busy')).toBe('true')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('renders default-slot content when not loading', async () => {
|
|
22
|
+
const wrapper = mount(LoadingArea, {
|
|
23
|
+
props: {
|
|
24
|
+
loading: false,
|
|
25
|
+
},
|
|
26
|
+
slots: {
|
|
27
|
+
default: '<p class="ready">Ready</p>',
|
|
28
|
+
},
|
|
29
|
+
global: { stubs: globalStubs },
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
await nextTick()
|
|
33
|
+
|
|
34
|
+
expect(wrapper.find('[role="status"]').exists()).toBe(false)
|
|
35
|
+
expect(wrapper.find('.ready').text()).toBe('Ready')
|
|
36
|
+
expect(wrapper.attributes('aria-busy')).toBe('false')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('keeps content mounted in overlay mode while loading', async () => {
|
|
40
|
+
const wrapper = mount(LoadingArea, {
|
|
41
|
+
props: {
|
|
42
|
+
loading: true,
|
|
43
|
+
overlay: true,
|
|
44
|
+
},
|
|
45
|
+
slots: {
|
|
46
|
+
default: '<p class="ready">Ready</p>',
|
|
47
|
+
},
|
|
48
|
+
global: { stubs: globalStubs },
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
await nextTick()
|
|
52
|
+
|
|
53
|
+
expect(wrapper.find('[role="status"]').exists()).toBe(true)
|
|
54
|
+
expect(wrapper.find('.ready').exists()).toBe(true)
|
|
55
|
+
expect(wrapper.classes()).toContain('is-overlay')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('supports a custom placeholder slot', () => {
|
|
59
|
+
const wrapper = mount(LoadingArea, {
|
|
60
|
+
props: {
|
|
61
|
+
loading: true,
|
|
62
|
+
},
|
|
63
|
+
slots: {
|
|
64
|
+
placeholder: '<span class="custom">Please wait</span>',
|
|
65
|
+
},
|
|
66
|
+
global: { stubs: globalStubs },
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
expect(wrapper.find('.custom').text()).toBe('Please wait')
|
|
70
|
+
expect(wrapper.text()).not.toContain('Loading…')
|
|
71
|
+
})
|
|
72
|
+
})
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div
|
|
3
|
+
class="loading-area"
|
|
4
|
+
:class="{
|
|
5
|
+
'is-loading': loading,
|
|
6
|
+
'is-overlay': loading && overlay && hasContent,
|
|
7
|
+
}"
|
|
8
|
+
:style="rootStyle"
|
|
9
|
+
:aria-busy="loading ? 'true' : 'false'"
|
|
10
|
+
>
|
|
11
|
+
<div
|
|
12
|
+
v-if="loading"
|
|
13
|
+
class="loading-area-placeholder"
|
|
14
|
+
role="status"
|
|
15
|
+
aria-live="polite"
|
|
16
|
+
>
|
|
17
|
+
<slot name="placeholder">
|
|
18
|
+
<HugeiconsIcon
|
|
19
|
+
:icon="resolvedIcon"
|
|
20
|
+
class="loading-area-spinner"
|
|
21
|
+
:size="resolvedIconSize"
|
|
22
|
+
:strokeWidth="2"
|
|
23
|
+
aria-hidden="true"
|
|
24
|
+
/>
|
|
25
|
+
<p v-if="message" class="loading-area-message">{{ message }}</p>
|
|
26
|
+
</slot>
|
|
27
|
+
</div>
|
|
28
|
+
|
|
29
|
+
<div
|
|
30
|
+
v-if="hasContent"
|
|
31
|
+
v-show="!loading || overlay"
|
|
32
|
+
class="loading-area-content"
|
|
33
|
+
:aria-hidden="loading && overlay ? 'true' : undefined"
|
|
34
|
+
>
|
|
35
|
+
<slot />
|
|
36
|
+
</div>
|
|
37
|
+
</div>
|
|
38
|
+
</template>
|
|
39
|
+
|
|
40
|
+
<script setup>
|
|
41
|
+
import { computed, useSlots } from 'vue'
|
|
42
|
+
import { HugeiconsIcon } from '@hugeicons/vue'
|
|
43
|
+
import { Loading03Icon } from '@hugeicons/core-free-icons'
|
|
44
|
+
|
|
45
|
+
const props = defineProps({
|
|
46
|
+
/**
|
|
47
|
+
* When true, shows the loading placeholder (and optionally overlays content).
|
|
48
|
+
*/
|
|
49
|
+
loading: {
|
|
50
|
+
type: Boolean,
|
|
51
|
+
default: true,
|
|
52
|
+
},
|
|
53
|
+
/**
|
|
54
|
+
* Status text announced to assistive tech and shown under the spinner.
|
|
55
|
+
* Pass an empty string to hide the message.
|
|
56
|
+
*/
|
|
57
|
+
message: {
|
|
58
|
+
type: String,
|
|
59
|
+
default: 'Loading…',
|
|
60
|
+
},
|
|
61
|
+
/**
|
|
62
|
+
* Keep default-slot content in the layout and cover it with the placeholder.
|
|
63
|
+
*/
|
|
64
|
+
overlay: {
|
|
65
|
+
type: Boolean,
|
|
66
|
+
default: false,
|
|
67
|
+
},
|
|
68
|
+
/**
|
|
69
|
+
* Hugeicons icon definition. Defaults to Loading03Icon.
|
|
70
|
+
*/
|
|
71
|
+
icon: {
|
|
72
|
+
type: [Object, Array, Function],
|
|
73
|
+
default: null,
|
|
74
|
+
},
|
|
75
|
+
/**
|
|
76
|
+
* Spinner size in px, or a CSS length string (e.g. "1.5em").
|
|
77
|
+
*/
|
|
78
|
+
iconSize: {
|
|
79
|
+
type: [Number, String],
|
|
80
|
+
default: 28,
|
|
81
|
+
},
|
|
82
|
+
/**
|
|
83
|
+
* Minimum height of the loading area (number = px, or any CSS length).
|
|
84
|
+
*/
|
|
85
|
+
minHeight: {
|
|
86
|
+
type: [Number, String],
|
|
87
|
+
default: null,
|
|
88
|
+
},
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
const slots = useSlots()
|
|
92
|
+
|
|
93
|
+
const hasContent = computed(() => typeof slots.default === 'function')
|
|
94
|
+
|
|
95
|
+
const resolvedIcon = computed(() => props.icon || Loading03Icon)
|
|
96
|
+
|
|
97
|
+
const resolvedIconSize = computed(() => {
|
|
98
|
+
if (typeof props.iconSize === 'number') {
|
|
99
|
+
return props.iconSize
|
|
100
|
+
}
|
|
101
|
+
const parsed = Number.parseFloat(props.iconSize)
|
|
102
|
+
return Number.isFinite(parsed) ? parsed : 28
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
const rootStyle = computed(() => {
|
|
106
|
+
if (props.minHeight == null || props.minHeight === '') {
|
|
107
|
+
return undefined
|
|
108
|
+
}
|
|
109
|
+
const value = typeof props.minHeight === 'number'
|
|
110
|
+
? `${props.minHeight}px`
|
|
111
|
+
: props.minHeight
|
|
112
|
+
return { minHeight: value }
|
|
113
|
+
})
|
|
114
|
+
</script>
|
|
115
|
+
|
|
116
|
+
<style scoped>
|
|
117
|
+
.loading-area {
|
|
118
|
+
position: relative;
|
|
119
|
+
display: block;
|
|
120
|
+
min-width: 0;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.loading-area-placeholder {
|
|
124
|
+
display: flex;
|
|
125
|
+
flex-direction: column;
|
|
126
|
+
align-items: center;
|
|
127
|
+
justify-content: center;
|
|
128
|
+
gap: 0.75rem;
|
|
129
|
+
padding: 2rem 1rem;
|
|
130
|
+
text-align: center;
|
|
131
|
+
color: var(--table-muted-fg, var(--muted-text-color));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.loading-area.is-overlay > .loading-area-placeholder {
|
|
135
|
+
position: absolute;
|
|
136
|
+
inset: 0;
|
|
137
|
+
z-index: 1;
|
|
138
|
+
box-sizing: border-box;
|
|
139
|
+
background-color: color-mix(in srgb, var(--section-bg-color, #fff) 78%, transparent);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.loading-area-spinner {
|
|
143
|
+
display: block;
|
|
144
|
+
flex: 0 0 auto;
|
|
145
|
+
animation: loading-area-spin 0.8s linear infinite;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
.loading-area-message {
|
|
149
|
+
margin: 0;
|
|
150
|
+
font-size: 0.95em;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
.loading-area-content {
|
|
154
|
+
min-width: 0;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
.loading-area.is-overlay > .loading-area-content {
|
|
158
|
+
pointer-events: none;
|
|
159
|
+
user-select: none;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
@keyframes loading-area-spin {
|
|
163
|
+
from {
|
|
164
|
+
transform: rotate(0deg);
|
|
165
|
+
}
|
|
166
|
+
to {
|
|
167
|
+
transform: rotate(360deg);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
@media (prefers-reduced-motion: reduce) {
|
|
172
|
+
.loading-area-spinner {
|
|
173
|
+
animation: none;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
</style>
|