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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "picocrank",
3
- "version": "1.31.1",
3
+ "version": "1.33.0",
4
4
  "type": "module",
5
5
  "description": "A set of zero dependency Vue components built on the Femtocrank global CSS theme.",
6
6
  "main": "vite.config.js",
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?.querySelector('button')?.focus()
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
- <div
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
- <button
1080
- v-if="internalViewMode !== 'day'"
1081
- type="button"
1082
- role="menuitem"
1083
- class="calendar-day-context-menu-item"
1084
- @click="showOnlyThisDay"
1085
- >
1086
- Show only this day
1087
- </button>
1088
- <button
1089
- v-if="internalViewMode !== 'week'"
1090
- type="button"
1091
- role="menuitem"
1092
- class="calendar-day-context-menu-item"
1093
- @click="showOnlyThisWeek"
1094
- >
1095
- Show only this week
1096
- </button>
1097
- <button
1098
- v-if="internalViewMode !== 'month'"
1099
- type="button"
1100
- role="menuitem"
1101
- class="calendar-day-context-menu-item"
1102
- @click="showFullMonth"
1103
- >
1104
- Show full month
1105
- </button>
1106
- </div>
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>