@pienter/ui 0.3.0 → 0.7.1
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/CHANGELOG.md +258 -0
- package/CONVENTIONS.md +342 -385
- package/README.md +33 -18
- package/components/display/record-details/RecordDetails.vue +61 -0
- package/components/display/record-details/record-details.css +37 -0
- package/components/display/record-details/types.ts +8 -0
- package/components/feedback/toast/toast.css +1 -1
- package/components/form/block-editor/BlockEditor.vue +454 -0
- package/components/form/block-editor/block-editor.css +149 -0
- package/components/form/block-editor/types.ts +15 -0
- package/components/form/combobox/Combobox.vue +42 -72
- package/components/form/combobox/combobox.css +1 -1
- package/components/form/form/Form.vue +1 -2
- package/components/form/label/label.css +1 -1
- package/components/form/number-field/NumberField.vue +1 -2
- package/components/form/number-field/number-field.css +1 -1
- package/components/form/radio-group/RadioGroup.vue +2 -5
- package/components/form/record-form/RecordFields.vue +128 -0
- package/components/form/record-form/RecordForm.vue +116 -0
- package/components/form/record-form/fields.ts +20 -0
- package/components/form/record-form/record-form.css +15 -0
- package/components/form/record-form/types.ts +28 -0
- package/components/form/slider/slider.css +2 -3
- package/components/form/tags-input/tags-input.css +1 -2
- package/components/form/text-input/text-input.css +2 -0
- package/components/form/textarea/textarea.css +1 -1
- package/components/layout/accordion/Accordion.vue +6 -27
- package/components/layout/collapsible/Collapsible.vue +9 -19
- package/components/layout/index/Index.vue +353 -0
- package/components/layout/index/index.css +114 -0
- package/components/layout/index/useIndex.ts +390 -0
- package/components/layout/table/table.css +5 -4
- package/components/navigation/breadcrumb/Breadcrumb.vue +24 -5
- package/components/navigation/breadcrumb/breadcrumb.css +15 -0
- package/components/navigation/sidebar/Sidebar.vue +329 -84
- package/components/navigation/sidebar/SidebarMenuItem.vue +199 -0
- package/components/navigation/sidebar/context.ts +21 -0
- package/components/navigation/sidebar/sidebar.css +346 -78
- package/components/navigation/sidebar/types.ts +13 -1
- package/components/navigation/tabs/Tabs.vue +6 -0
- package/components/overlay/alert-dialog/AlertDialog.vue +10 -31
- package/components/overlay/command/Command.vue +10 -38
- package/components/overlay/command/command.css +1 -1
- package/components/overlay/dropdown-menu/DropdownMenu.vue +37 -62
- package/components/overlay/modal/Modal.vue +7 -28
- package/components/overlay/popover/Popover.vue +9 -31
- package/components/overlay/sheet/Sheet.vue +7 -28
- package/components/overlay/tooltip/Tooltip.vue +14 -19
- package/{utils/controllers/dialog.ts → composables/useDialog.ts} +90 -78
- package/composables/useDisclosure.ts +113 -0
- package/composables/useEventListener.ts +16 -0
- package/composables/useMenu.ts +212 -0
- package/{utils/controllers/popover.ts → composables/usePopover.ts} +107 -120
- package/package.json +16 -40
- package/styles/0-settings/colors.css +10 -0
- package/styles/0-settings/layout.css +18 -0
- package/styles/0-settings/motion.css +2 -2
- package/styles/0-settings/spacing.css +3 -1
- package/utils/a11y/focus.ts +9 -3
- package/utils/cms/index.ts +283 -0
- package/utils/cms/schema.json +126 -0
- package/utils/navigation/sidebar.ts +97 -0
- package/utils/validation/form.ts +6 -9
- package/components/action/button/Button.astro +0 -95
- package/components/action/button/IconButton.astro +0 -86
- package/components/display/avatar/Avatar.astro +0 -17
- package/components/display/avatar/AvatarStack.astro +0 -9
- package/components/display/badge/Badge.astro +0 -15
- package/components/display/empty/Empty.astro +0 -9
- package/components/display/icon/Icon.astro +0 -52
- package/components/feedback/alert/Alert.astro +0 -52
- package/components/feedback/progress/Progress.astro +0 -68
- package/components/feedback/skeleton/Skeleton.astro +0 -32
- package/components/feedback/spinner/Spinner.astro +0 -25
- package/components/feedback/toast/Toast.astro +0 -50
- package/components/form/checkbox/Checkbox.astro +0 -79
- package/components/form/date-input/DateInput.astro +0 -105
- package/components/form/form/Form.astro +0 -106
- package/components/form/input-otp/InputOTP.astro +0 -147
- package/components/form/label/Label.astro +0 -13
- package/components/form/number-field/NumberField.astro +0 -142
- package/components/form/radio-group/RadioGroup.astro +0 -105
- package/components/form/select/Select.astro +0 -105
- package/components/form/slider/Slider.astro +0 -205
- package/components/form/switch/Switch.astro +0 -75
- package/components/form/tags-input/TagsInput.astro +0 -153
- package/components/form/text-input/TextInput.astro +0 -84
- package/components/form/textarea/Textarea.astro +0 -86
- package/components/layout/card/Card.astro +0 -13
- package/components/layout/separator/Separator.astro +0 -31
- package/components/layout/table/Table.astro +0 -116
- package/components/navigation/breadcrumb/Breadcrumb.astro +0 -36
- package/components/navigation/navbar/Navbar.astro +0 -62
- package/components/navigation/pagination/PaginationFooter.astro +0 -24
- package/components/navigation/sidebar/Sidebar.astro +0 -132
- package/components/navigation/sidebar/SidebarItemRender.astro +0 -83
- package/components/navigation/sidebar/SidebarItemRender.vue +0 -98
- package/components/overlay/alert-dialog/AlertDialog.astro +0 -112
- package/components/overlay/modal/Modal.astro +0 -66
- package/utils/controllers/disclosure.ts +0 -117
- package/utils/controllers/form.ts +0 -524
- package/utils/controllers/index.ts +0 -39
- package/utils/controllers/menu.ts +0 -255
- package/utils/controllers/number-field.ts +0 -103
- package/utils/controllers/otp.ts +0 -252
- package/utils/controllers/sidebar.ts +0 -610
- package/utils/controllers/slider.ts +0 -336
- package/utils/controllers/tags-input.ts +0 -255
- package/utils/controllers/toast.ts +0 -426
package/README.md
CHANGED
|
@@ -1,20 +1,13 @@
|
|
|
1
1
|
# @pienter/ui
|
|
2
2
|
|
|
3
|
-
Shared Pienter UI package with
|
|
3
|
+
Shared Pienter UI package with Vue components, CSS, icons, and browser utilities.
|
|
4
4
|
|
|
5
|
-
This package publishes raw source files. Consuming projects compile the Vue,
|
|
5
|
+
This package publishes raw source files. Consuming projects compile the Vue, TypeScript, and CSS files with their own toolchain.
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
9
9
|
```sh
|
|
10
|
-
npm install @pienter/ui
|
|
11
|
-
```
|
|
12
|
-
|
|
13
|
-
Install the framework peer you use:
|
|
14
|
-
|
|
15
|
-
```sh
|
|
16
|
-
npm install vue
|
|
17
|
-
npm install astro
|
|
10
|
+
npm install @pienter/ui vue
|
|
18
11
|
```
|
|
19
12
|
|
|
20
13
|
## Usage
|
|
@@ -24,14 +17,6 @@ import Button from '@pienter/ui/components/Button.vue';
|
|
|
24
17
|
import '@pienter/ui/styles';
|
|
25
18
|
```
|
|
26
19
|
|
|
27
|
-
Astro components are available beside Vue components:
|
|
28
|
-
|
|
29
|
-
```astro
|
|
30
|
-
---
|
|
31
|
-
import Button from '@pienter/ui/components/Button.astro';
|
|
32
|
-
---
|
|
33
|
-
```
|
|
34
|
-
|
|
35
20
|
Components use explicit flat public entrypoints. Intentional shared types are
|
|
36
21
|
available through module type entrypoints:
|
|
37
22
|
|
|
@@ -47,6 +32,36 @@ import { icons } from '@pienter/ui/icons';
|
|
|
47
32
|
import { generateId } from '@pienter/ui/utils/a11y/id.js';
|
|
48
33
|
```
|
|
49
34
|
|
|
35
|
+
### CMS pages
|
|
36
|
+
|
|
37
|
+
Compose ordinary Vue routes with `Index`, `RecordForm`, and `RecordDetails`:
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
import Index from '@pienter/ui/components/Index.vue';
|
|
41
|
+
import RecordForm from '@pienter/ui/components/RecordForm.vue';
|
|
42
|
+
import RecordFields from '@pienter/ui/components/RecordFields.vue';
|
|
43
|
+
import BlockEditor from '@pienter/ui/components/BlockEditor.vue';
|
|
44
|
+
import RecordDetails from '@pienter/ui/components/RecordDetails.vue';
|
|
45
|
+
import type { RecordFormField } from '@pienter/ui/components/RecordForm.types';
|
|
46
|
+
import { decodeListQuery, encodeListQuery } from '@pienter/ui/utils/cms';
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Index receives `load(query, { signal })`, columns and query options. It handles
|
|
50
|
+
loading, search, pagination, errors and retry; `sync-query` enables Vue Router URL
|
|
51
|
+
synchronization. Index requires the `vue-router` package; an installed router
|
|
52
|
+
instance is needed only when synchronization is enabled. RecordForm receives `v-model`, fields and validation issues,
|
|
53
|
+
emitting a JSON payload on `submit`. RecordDetails receives a record and fields.
|
|
54
|
+
Use slots for custom cells, controls, values and actions. Your pages own route
|
|
55
|
+
declarations, endpoint functions and saving. Use RecordForm’s `fields` slot and
|
|
56
|
+
RecordFields to put groups in sidebars and tab panels while retaining one form.
|
|
57
|
+
BlockEditor accepts `v-model`, block creation factories and a `block` slot; it
|
|
58
|
+
provides drag-and-drop, keyboard movement, add/remove and collapse controls.
|
|
59
|
+
|
|
60
|
+
The [CMS guide](../../docs/cms/README.md) contains working examples. The
|
|
61
|
+
[backend contract](../../docs/cms/backend-contract.md) defines query parameters,
|
|
62
|
+
response envelopes and validation paths; its JSON Schema ships at
|
|
63
|
+
`@pienter/ui/utils/cms/schema.json`.
|
|
64
|
+
|
|
50
65
|
### Themes
|
|
51
66
|
|
|
52
67
|
The default theme follows the operating-system color preference, using
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
export type { RecordDetailsField } from './types.js';
|
|
3
|
+
</script>
|
|
4
|
+
|
|
5
|
+
<script setup lang="ts" generic="T extends object">
|
|
6
|
+
import { readPath } from '../../../utils/cms/index.js';
|
|
7
|
+
import type { RecordDetailsField } from './types.js';
|
|
8
|
+
|
|
9
|
+
const props = withDefaults(
|
|
10
|
+
defineProps<{
|
|
11
|
+
record: T;
|
|
12
|
+
fields: readonly RecordDetailsField[];
|
|
13
|
+
emptyValue?: string;
|
|
14
|
+
}>(),
|
|
15
|
+
{ emptyValue: '—' },
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
function valueFor(field: RecordDetailsField): unknown {
|
|
19
|
+
return readPath(props.record, field.path ?? [field.name]);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function displayValue(value: unknown): string {
|
|
23
|
+
if (value === undefined || value === null || value === '')
|
|
24
|
+
return props.emptyValue;
|
|
25
|
+
if (typeof value === 'boolean') return value ? 'Yes' : 'No';
|
|
26
|
+
if (typeof value === 'object') return JSON.stringify(value);
|
|
27
|
+
return String(value);
|
|
28
|
+
}
|
|
29
|
+
</script>
|
|
30
|
+
|
|
31
|
+
<template>
|
|
32
|
+
<div class="pui-record-details">
|
|
33
|
+
<dl class="pui-record-details__list">
|
|
34
|
+
<div
|
|
35
|
+
v-for="field in fields"
|
|
36
|
+
:key="field.name"
|
|
37
|
+
class="pui-record-details__field"
|
|
38
|
+
>
|
|
39
|
+
<dt class="pui-record-details__label">{{ field.label }}</dt>
|
|
40
|
+
<dd class="pui-record-details__value">
|
|
41
|
+
<slot
|
|
42
|
+
:name="`field:${field.name}`"
|
|
43
|
+
:field="field"
|
|
44
|
+
:value="valueFor(field)"
|
|
45
|
+
:record="record"
|
|
46
|
+
>
|
|
47
|
+
{{ displayValue(valueFor(field)) }}
|
|
48
|
+
</slot>
|
|
49
|
+
</dd>
|
|
50
|
+
</div>
|
|
51
|
+
</dl>
|
|
52
|
+
<slot />
|
|
53
|
+
<div v-if="$slots.actions" class="pui-record-details__actions">
|
|
54
|
+
<slot name="actions" :record="record" />
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
</template>
|
|
58
|
+
|
|
59
|
+
<style>
|
|
60
|
+
@import './record-details.css';
|
|
61
|
+
</style>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
@layer components {
|
|
2
|
+
.pui-record-details {
|
|
3
|
+
display: grid;
|
|
4
|
+
gap: var(--space-s);
|
|
5
|
+
|
|
6
|
+
& .pui-record-details__list {
|
|
7
|
+
display: grid;
|
|
8
|
+
margin: 0;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
& .pui-record-details__field {
|
|
12
|
+
display: grid;
|
|
13
|
+
grid-template-columns: minmax(0, 1fr) minmax(0, 3fr);
|
|
14
|
+
gap: var(--space-s);
|
|
15
|
+
padding-block: var(--space-xs);
|
|
16
|
+
border-block-end: var(--stroke-sm) solid var(--border-clr-base);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
& .pui-record-details__label {
|
|
20
|
+
color: var(--text-clr-dim);
|
|
21
|
+
font-size: var(--step--1);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
& .pui-record-details__value {
|
|
25
|
+
margin: 0;
|
|
26
|
+
min-inline-size: 0;
|
|
27
|
+
overflow-wrap: anywhere;
|
|
28
|
+
white-space: pre-wrap;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
& .pui-record-details__actions {
|
|
32
|
+
display: flex;
|
|
33
|
+
flex-wrap: wrap;
|
|
34
|
+
gap: var(--space-xs);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
export type { BlockType, ContentBlock, BlockPatch } from './types.js';
|
|
3
|
+
</script>
|
|
4
|
+
|
|
5
|
+
<script setup lang="ts" generic="T extends ContentBlock">
|
|
6
|
+
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
|
|
7
|
+
import Button from '../../action/button/Button.vue';
|
|
8
|
+
import IconButton from '../../action/button/IconButton.vue';
|
|
9
|
+
import Icon from '../../display/icon/Icon.vue';
|
|
10
|
+
import { announce } from '../../../utils/a11y/live-region.js';
|
|
11
|
+
import { generateId } from '../../../utils/a11y/id.js';
|
|
12
|
+
import type { BlockPatch, BlockType, ContentBlock } from './types.js';
|
|
13
|
+
|
|
14
|
+
const props = withDefaults(
|
|
15
|
+
defineProps<{
|
|
16
|
+
modelValue: readonly T[];
|
|
17
|
+
blockTypes: readonly BlockType<T>[];
|
|
18
|
+
/** Optional controlled list of collapsed block ids. */
|
|
19
|
+
collapsed?: readonly string[];
|
|
20
|
+
disabled?: boolean;
|
|
21
|
+
label?: string;
|
|
22
|
+
blockLabel?: (block: T, index: number) => string;
|
|
23
|
+
}>(),
|
|
24
|
+
{
|
|
25
|
+
disabled: false,
|
|
26
|
+
collapsed: undefined,
|
|
27
|
+
label: 'Content blocks',
|
|
28
|
+
blockLabel: undefined,
|
|
29
|
+
},
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
const emit = defineEmits<{
|
|
33
|
+
'update:modelValue': [blocks: T[]];
|
|
34
|
+
'update:collapsed': [ids: string[]];
|
|
35
|
+
}>();
|
|
36
|
+
defineSlots<{
|
|
37
|
+
block: (scope: {
|
|
38
|
+
block: T;
|
|
39
|
+
index: number;
|
|
40
|
+
update: (patch: BlockPatch<T>) => void;
|
|
41
|
+
disabled: boolean;
|
|
42
|
+
}) => unknown;
|
|
43
|
+
summary?: (scope: { block: T; index: number }) => unknown;
|
|
44
|
+
empty?: () => unknown;
|
|
45
|
+
}>();
|
|
46
|
+
|
|
47
|
+
const id = generateId('blocks');
|
|
48
|
+
const localCollapsed = ref<string[]>([]);
|
|
49
|
+
const collapsed = computed(
|
|
50
|
+
() => new Set(props.collapsed ?? localCollapsed.value),
|
|
51
|
+
);
|
|
52
|
+
const dragging = ref<string>();
|
|
53
|
+
const dropIndex = ref(0);
|
|
54
|
+
const items = new Map<string, HTMLElement>();
|
|
55
|
+
let gesture:
|
|
56
|
+
| {
|
|
57
|
+
id: string;
|
|
58
|
+
pointerId: number;
|
|
59
|
+
startX: number;
|
|
60
|
+
startY: number;
|
|
61
|
+
y: number;
|
|
62
|
+
}
|
|
63
|
+
| undefined;
|
|
64
|
+
let pointerListeners: AbortController | undefined;
|
|
65
|
+
let scrollFrame = 0;
|
|
66
|
+
let scrollParent: HTMLElement | undefined;
|
|
67
|
+
|
|
68
|
+
function setItem(key: string, element: HTMLElement | null) {
|
|
69
|
+
if (element) items.set(key, element);
|
|
70
|
+
else items.delete(key);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function typeLabel(block: T) {
|
|
74
|
+
return (
|
|
75
|
+
props.blockTypes.find((type) => type.type === block.type)?.label ??
|
|
76
|
+
block.type
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function title(block: T, index: number) {
|
|
81
|
+
return props.blockLabel?.(block, index) || typeLabel(block);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function bodyId(block: T) {
|
|
85
|
+
return `${id}-${encodeURIComponent(block.id)}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function focusBlock(key: string) {
|
|
89
|
+
void nextTick(() =>
|
|
90
|
+
items
|
|
91
|
+
.get(key)
|
|
92
|
+
?.querySelector<HTMLButtonElement>('[data-block-toggle]')
|
|
93
|
+
?.focus(),
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function add(type: BlockType<T>) {
|
|
98
|
+
if (props.disabled) return;
|
|
99
|
+
const block = type.create();
|
|
100
|
+
if (
|
|
101
|
+
!block.id ||
|
|
102
|
+
block.type !== type.type ||
|
|
103
|
+
props.modelValue.some((item) => item.id === block.id)
|
|
104
|
+
) {
|
|
105
|
+
throw new TypeError(
|
|
106
|
+
'Block factories must return the requested type and a unique, non-empty id.',
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
emit('update:modelValue', [...props.modelValue, { ...block }]);
|
|
110
|
+
focusBlock(block.id);
|
|
111
|
+
announce(`${type.label} added. ${props.modelValue.length + 1} blocks.`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function update(key: string, patch: BlockPatch<T>) {
|
|
115
|
+
if (props.disabled) return;
|
|
116
|
+
emit(
|
|
117
|
+
'update:modelValue',
|
|
118
|
+
props.modelValue.map((block) =>
|
|
119
|
+
block.id === key
|
|
120
|
+
? { ...block, ...patch, id: block.id, type: block.type }
|
|
121
|
+
: block,
|
|
122
|
+
),
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function remove(key: string) {
|
|
127
|
+
if (props.disabled) return;
|
|
128
|
+
const index = props.modelValue.findIndex((block) => block.id === key);
|
|
129
|
+
const block = props.modelValue[index];
|
|
130
|
+
if (!block) return;
|
|
131
|
+
const remaining = props.modelValue.filter((block) => block.id !== key);
|
|
132
|
+
emit('update:modelValue', remaining);
|
|
133
|
+
setCollapsed([...collapsed.value].filter((id) => id !== key));
|
|
134
|
+
const next = remaining[Math.min(index, remaining.length - 1)];
|
|
135
|
+
if (next) focusBlock(next.id);
|
|
136
|
+
else
|
|
137
|
+
void nextTick(() =>
|
|
138
|
+
document
|
|
139
|
+
.getElementById(`${id}-add`)
|
|
140
|
+
?.querySelector<HTMLButtonElement>('button')
|
|
141
|
+
?.focus(),
|
|
142
|
+
);
|
|
143
|
+
announce(`${title(block, index)} removed. ${remaining.length} blocks.`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function move(key: string, target: number) {
|
|
147
|
+
if (props.disabled) return;
|
|
148
|
+
const source = props.modelValue.findIndex((block) => block.id === key);
|
|
149
|
+
if (
|
|
150
|
+
source < 0 ||
|
|
151
|
+
target < 0 ||
|
|
152
|
+
target >= props.modelValue.length ||
|
|
153
|
+
source === target
|
|
154
|
+
)
|
|
155
|
+
return;
|
|
156
|
+
const blocks = [...props.modelValue];
|
|
157
|
+
const block = blocks.splice(source, 1)[0]!;
|
|
158
|
+
blocks.splice(target, 0, block);
|
|
159
|
+
emit('update:modelValue', blocks);
|
|
160
|
+
focusBlock(key);
|
|
161
|
+
announce(
|
|
162
|
+
`${title(block, source)} moved to position ${target + 1} of ${blocks.length}.`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function toggle(key: string) {
|
|
167
|
+
if (props.disabled) return;
|
|
168
|
+
const next = new Set(collapsed.value);
|
|
169
|
+
if (next.has(key)) next.delete(key);
|
|
170
|
+
else next.add(key);
|
|
171
|
+
setCollapsed([...next]);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function setCollapsed(ids: string[]) {
|
|
175
|
+
if (props.collapsed === undefined) localCollapsed.value = ids;
|
|
176
|
+
emit('update:collapsed', ids);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const otherBlocks = computed(() =>
|
|
180
|
+
props.modelValue.filter((block) => block.id !== dragging.value),
|
|
181
|
+
);
|
|
182
|
+
function indicator(key: string) {
|
|
183
|
+
if (!dragging.value) return undefined;
|
|
184
|
+
if (otherBlocks.value[dropIndex.value]?.id === key) return 'before';
|
|
185
|
+
if (
|
|
186
|
+
dropIndex.value === otherBlocks.value.length &&
|
|
187
|
+
otherBlocks.value.at(-1)?.id === key
|
|
188
|
+
)
|
|
189
|
+
return 'after';
|
|
190
|
+
return undefined;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function locateDrop(y: number) {
|
|
194
|
+
dropIndex.value = otherBlocks.value.filter((block) => {
|
|
195
|
+
const rect = items.get(block.id)?.getBoundingClientRect();
|
|
196
|
+
return rect && y > rect.top + rect.height / 2;
|
|
197
|
+
}).length;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function autoScroll() {
|
|
201
|
+
if (!gesture || !dragging.value) return;
|
|
202
|
+
const bounds = scrollParent?.getBoundingClientRect();
|
|
203
|
+
const top = bounds?.top ?? 0;
|
|
204
|
+
const bottom = bounds?.bottom ?? window.innerHeight;
|
|
205
|
+
const delta = gesture.y < top + 48 ? -14 : gesture.y > bottom - 48 ? 14 : 0;
|
|
206
|
+
if (delta) {
|
|
207
|
+
if (scrollParent) scrollParent.scrollTop += delta;
|
|
208
|
+
else window.scrollBy(0, delta);
|
|
209
|
+
locateDrop(gesture.y);
|
|
210
|
+
}
|
|
211
|
+
scrollFrame = requestAnimationFrame(autoScroll);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function stopDrag() {
|
|
215
|
+
pointerListeners?.abort();
|
|
216
|
+
pointerListeners = undefined;
|
|
217
|
+
cancelAnimationFrame(scrollFrame);
|
|
218
|
+
gesture = undefined;
|
|
219
|
+
dragging.value = undefined;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function startDrag(event: PointerEvent, key: string) {
|
|
223
|
+
if (props.disabled || event.button !== 0 || props.modelValue.length < 2)
|
|
224
|
+
return;
|
|
225
|
+
stopDrag();
|
|
226
|
+
const handle = event.currentTarget as HTMLButtonElement;
|
|
227
|
+
handle.focus({ preventScroll: true });
|
|
228
|
+
gesture = {
|
|
229
|
+
id: key,
|
|
230
|
+
pointerId: event.pointerId,
|
|
231
|
+
startX: event.clientX,
|
|
232
|
+
startY: event.clientY,
|
|
233
|
+
y: event.clientY,
|
|
234
|
+
};
|
|
235
|
+
scrollParent = undefined;
|
|
236
|
+
for (
|
|
237
|
+
let parent = handle.parentElement;
|
|
238
|
+
parent;
|
|
239
|
+
parent = parent.parentElement
|
|
240
|
+
) {
|
|
241
|
+
if (
|
|
242
|
+
/(auto|scroll)/.test(getComputedStyle(parent).overflowY) &&
|
|
243
|
+
parent.scrollHeight > parent.clientHeight
|
|
244
|
+
) {
|
|
245
|
+
scrollParent = parent;
|
|
246
|
+
break;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
try {
|
|
250
|
+
handle.setPointerCapture(event.pointerId);
|
|
251
|
+
} catch {
|
|
252
|
+
/* Synthetic pointer events have no capturable pointer. */
|
|
253
|
+
}
|
|
254
|
+
pointerListeners = new AbortController();
|
|
255
|
+
const options = { signal: pointerListeners.signal };
|
|
256
|
+
window.addEventListener('pointermove', dragMove, options);
|
|
257
|
+
window.addEventListener('pointerup', finishDrag, options);
|
|
258
|
+
window.addEventListener('pointercancel', stopDrag, options);
|
|
259
|
+
window.addEventListener('blur', stopDrag, options);
|
|
260
|
+
window.addEventListener('keydown', cancelWithEscape, options);
|
|
261
|
+
event.preventDefault();
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function dragMove(event: PointerEvent) {
|
|
265
|
+
if (!gesture || event.pointerId !== gesture.pointerId) return;
|
|
266
|
+
gesture.y = event.clientY;
|
|
267
|
+
if (!dragging.value) {
|
|
268
|
+
if (
|
|
269
|
+
Math.hypot(
|
|
270
|
+
event.clientX - gesture.startX,
|
|
271
|
+
event.clientY - gesture.startY,
|
|
272
|
+
) < 5
|
|
273
|
+
)
|
|
274
|
+
return;
|
|
275
|
+
dragging.value = gesture.id;
|
|
276
|
+
scrollFrame = requestAnimationFrame(autoScroll);
|
|
277
|
+
}
|
|
278
|
+
locateDrop(event.clientY);
|
|
279
|
+
event.preventDefault();
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function finishDrag(event: PointerEvent) {
|
|
283
|
+
if (!gesture || event.pointerId !== gesture.pointerId) return;
|
|
284
|
+
const key = dragging.value;
|
|
285
|
+
const target = dropIndex.value;
|
|
286
|
+
stopDrag();
|
|
287
|
+
if (key) move(key, target);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function cancelWithEscape(event: KeyboardEvent) {
|
|
291
|
+
if (event.key === 'Escape' && gesture) {
|
|
292
|
+
event.preventDefault();
|
|
293
|
+
stopDrag();
|
|
294
|
+
announce('Reordering cancelled.');
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
watch(
|
|
299
|
+
() => props.disabled,
|
|
300
|
+
(disabled) => {
|
|
301
|
+
if (disabled) stopDrag();
|
|
302
|
+
},
|
|
303
|
+
);
|
|
304
|
+
watch(() => props.modelValue.map((block) => block.id).join('\u0000'), stopDrag);
|
|
305
|
+
onBeforeUnmount(stopDrag);
|
|
306
|
+
</script>
|
|
307
|
+
|
|
308
|
+
<template>
|
|
309
|
+
<div
|
|
310
|
+
class="pui-block-editor"
|
|
311
|
+
:data-disabled="disabled ? 'true' : undefined"
|
|
312
|
+
>
|
|
313
|
+
<p :id="`${id}-hint`" class="pui-block-editor__hint">
|
|
314
|
+
Drag to reorder, or use the move buttons.
|
|
315
|
+
</p>
|
|
316
|
+
<ol
|
|
317
|
+
v-if="modelValue.length"
|
|
318
|
+
class="pui-block-editor__list"
|
|
319
|
+
:aria-label="label"
|
|
320
|
+
:aria-describedby="`${id}-hint`"
|
|
321
|
+
>
|
|
322
|
+
<li
|
|
323
|
+
v-for="(block, index) in modelValue"
|
|
324
|
+
:key="block.id"
|
|
325
|
+
:ref="
|
|
326
|
+
(element) =>
|
|
327
|
+
setItem(block.id, element as HTMLElement | null)
|
|
328
|
+
"
|
|
329
|
+
class="pui-block-editor__item"
|
|
330
|
+
:data-block-id="block.id"
|
|
331
|
+
:data-dragging="dragging === block.id ? 'true' : undefined"
|
|
332
|
+
:data-drop="indicator(block.id)"
|
|
333
|
+
>
|
|
334
|
+
<div class="pui-block-editor__header">
|
|
335
|
+
<button
|
|
336
|
+
class="pui-block-editor__drag"
|
|
337
|
+
type="button"
|
|
338
|
+
:disabled="disabled || modelValue.length < 2"
|
|
339
|
+
:aria-label="`Drag ${title(block, index)}`"
|
|
340
|
+
:aria-describedby="`${id}-hint`"
|
|
341
|
+
@pointerdown="startDrag($event, block.id)"
|
|
342
|
+
>
|
|
343
|
+
<Icon name="arrow-up-down" size="sm" />
|
|
344
|
+
</button>
|
|
345
|
+
<div class="pui-block-editor__heading">
|
|
346
|
+
<button
|
|
347
|
+
type="button"
|
|
348
|
+
class="pui-block-editor__toggle"
|
|
349
|
+
data-block-toggle
|
|
350
|
+
:disabled="disabled"
|
|
351
|
+
:aria-label="`${collapsed.has(block.id) ? 'Expand' : 'Collapse'} ${title(block, index)}`"
|
|
352
|
+
:aria-expanded="
|
|
353
|
+
collapsed.has(block.id) ? 'false' : 'true'
|
|
354
|
+
"
|
|
355
|
+
:aria-controls="bodyId(block)"
|
|
356
|
+
@click="toggle(block.id)"
|
|
357
|
+
>
|
|
358
|
+
<Icon
|
|
359
|
+
:name="
|
|
360
|
+
collapsed.has(block.id)
|
|
361
|
+
? 'chevron-right'
|
|
362
|
+
: 'chevron-down'
|
|
363
|
+
"
|
|
364
|
+
size="sm"
|
|
365
|
+
/>
|
|
366
|
+
<span class="pui-block-editor__title">{{
|
|
367
|
+
title(block, index)
|
|
368
|
+
}}</span>
|
|
369
|
+
<span
|
|
370
|
+
v-if="title(block, index) !== typeLabel(block)"
|
|
371
|
+
class="pui-block-editor__type"
|
|
372
|
+
>{{ typeLabel(block) }}</span
|
|
373
|
+
>
|
|
374
|
+
</button>
|
|
375
|
+
<div
|
|
376
|
+
v-if="$slots.summary"
|
|
377
|
+
class="pui-block-editor__summary"
|
|
378
|
+
>
|
|
379
|
+
<slot
|
|
380
|
+
name="summary"
|
|
381
|
+
:block="block"
|
|
382
|
+
:index="index"
|
|
383
|
+
/>
|
|
384
|
+
</div>
|
|
385
|
+
</div>
|
|
386
|
+
<div class="pui-block-editor__actions">
|
|
387
|
+
<IconButton
|
|
388
|
+
name="arrow-up"
|
|
389
|
+
variant="ghost"
|
|
390
|
+
size="sm"
|
|
391
|
+
:label="`Move ${title(block, index)} up`"
|
|
392
|
+
:disabled="disabled || index === 0"
|
|
393
|
+
@click="move(block.id, index - 1)"
|
|
394
|
+
/>
|
|
395
|
+
<IconButton
|
|
396
|
+
name="arrow-down"
|
|
397
|
+
variant="ghost"
|
|
398
|
+
size="sm"
|
|
399
|
+
:label="`Move ${title(block, index)} down`"
|
|
400
|
+
:disabled="
|
|
401
|
+
disabled || index === modelValue.length - 1
|
|
402
|
+
"
|
|
403
|
+
@click="move(block.id, index + 1)"
|
|
404
|
+
/>
|
|
405
|
+
<IconButton
|
|
406
|
+
name="trash-2"
|
|
407
|
+
variant="ghost"
|
|
408
|
+
size="sm"
|
|
409
|
+
:label="`Remove ${title(block, index)}`"
|
|
410
|
+
:disabled="disabled"
|
|
411
|
+
@click="remove(block.id)"
|
|
412
|
+
/>
|
|
413
|
+
</div>
|
|
414
|
+
</div>
|
|
415
|
+
<div
|
|
416
|
+
:id="bodyId(block)"
|
|
417
|
+
:hidden="collapsed.has(block.id)"
|
|
418
|
+
class="pui-block-editor__body"
|
|
419
|
+
:inert="disabled ? true : undefined"
|
|
420
|
+
>
|
|
421
|
+
<slot
|
|
422
|
+
name="block"
|
|
423
|
+
:block="block"
|
|
424
|
+
:index="index"
|
|
425
|
+
:update="
|
|
426
|
+
(patch: BlockPatch<T>) => update(block.id, patch)
|
|
427
|
+
"
|
|
428
|
+
:disabled="disabled"
|
|
429
|
+
/>
|
|
430
|
+
</div>
|
|
431
|
+
</li>
|
|
432
|
+
</ol>
|
|
433
|
+
<div v-else class="pui-block-editor__empty">
|
|
434
|
+
<slot name="empty"
|
|
435
|
+
>No blocks yet. Add a block to start writing.</slot
|
|
436
|
+
>
|
|
437
|
+
</div>
|
|
438
|
+
<div :id="`${id}-add`" class="pui-block-editor__add">
|
|
439
|
+
<Button
|
|
440
|
+
v-for="type in blockTypes"
|
|
441
|
+
:key="type.type"
|
|
442
|
+
icon="plus"
|
|
443
|
+
size="sm"
|
|
444
|
+
:disabled="disabled"
|
|
445
|
+
@click="add(type)"
|
|
446
|
+
>Add {{ type.label }}</Button
|
|
447
|
+
>
|
|
448
|
+
</div>
|
|
449
|
+
</div>
|
|
450
|
+
</template>
|
|
451
|
+
|
|
452
|
+
<style>
|
|
453
|
+
@import './block-editor.css';
|
|
454
|
+
</style>
|