@pienter/ui 0.5.0 → 0.8.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.
Files changed (66) hide show
  1. package/CHANGELOG.md +149 -0
  2. package/CONVENTIONS.md +91 -28
  3. package/README.md +66 -0
  4. package/components/display/record-details/RecordDetails.vue +61 -0
  5. package/components/display/record-details/record-details.css +37 -0
  6. package/components/display/record-details/types.ts +8 -0
  7. package/components/feedback/toast/Toast.vue +3 -4
  8. package/components/feedback/toast/ToastHost.vue +165 -0
  9. package/components/feedback/toast/toast.ts +116 -0
  10. package/components/feedback/toast/types.ts +48 -0
  11. package/components/form/block-editor/BlockEditor.vue +455 -0
  12. package/components/form/block-editor/block-editor.css +149 -0
  13. package/components/form/block-editor/types.ts +15 -0
  14. package/components/form/checkbox/Checkbox.vue +10 -2
  15. package/components/form/combobox/Combobox.vue +32 -39
  16. package/components/form/combobox/combobox.css +1 -1
  17. package/components/form/date-input/DateInput.vue +10 -2
  18. package/components/form/date-input/date-input.css +2 -17
  19. package/components/form/form/Form.vue +10 -9
  20. package/components/form/form/form.css +2 -22
  21. package/components/form/input-otp/InputOTP.vue +1 -1
  22. package/components/form/input-otp/input-otp.css +1 -1
  23. package/components/form/label/label.css +2 -11
  24. package/components/form/number-field/NumberField.vue +10 -3
  25. package/components/form/number-field/number-field.css +3 -3
  26. package/components/form/radio-group/RadioGroup.vue +1 -1
  27. package/components/form/record-form/RecordFields.vue +128 -0
  28. package/components/form/record-form/RecordForm.vue +116 -0
  29. package/components/form/record-form/fields.ts +20 -0
  30. package/components/form/record-form/record-form.css +15 -0
  31. package/components/form/record-form/types.ts +28 -0
  32. package/components/form/select/Select.vue +13 -4
  33. package/components/form/select/select.css +6 -9
  34. package/components/form/slider/Slider.vue +1 -1
  35. package/components/form/switch/Switch.vue +1 -1
  36. package/components/form/tags-input/TagsInput.vue +17 -2
  37. package/components/form/tags-input/tags-input.css +16 -12
  38. package/components/form/text-input/TextInput.vue +10 -2
  39. package/components/form/text-input/text-input.css +10 -129
  40. package/components/form/textarea/Textarea.vue +10 -2
  41. package/components/form/textarea/textarea.css +3 -19
  42. package/components/layout/app-layout/AppLayout.vue +116 -0
  43. package/components/layout/app-layout/app-layout.css +115 -0
  44. package/components/layout/index/Index.vue +373 -0
  45. package/components/layout/index/index.css +157 -0
  46. package/components/layout/index/useIndex.ts +407 -0
  47. package/components/layout/table/DataTable.vue +14 -1
  48. package/components/layout/table/Table.vue +12 -0
  49. package/components/layout/table/table.css +2 -1
  50. package/components/navigation/breadcrumb/Breadcrumb.vue +24 -5
  51. package/components/navigation/breadcrumb/breadcrumb.css +20 -0
  52. package/components/navigation/sidebar/Sidebar.vue +11 -8
  53. package/components/navigation/sidebar/sidebar.css +23 -16
  54. package/components/navigation/tabs/Tabs.vue +6 -0
  55. package/components/navigation/tabs/tabs.css +7 -7
  56. package/composables/useMenu.ts +20 -27
  57. package/package.json +17 -2
  58. package/styles/0-settings/colors.css +10 -0
  59. package/styles/4-components/form-field.css +112 -0
  60. package/styles/4-components/index.css +1 -0
  61. package/styles/main.css +1 -0
  62. package/utils/a11y/focus.ts +9 -3
  63. package/utils/a11y/index.ts +5 -1
  64. package/utils/a11y/live-region.ts +2 -1
  65. package/utils/cms/index.ts +283 -0
  66. package/utils/cms/schema.json +126 -0
@@ -0,0 +1,165 @@
1
+ <script setup lang="ts">
2
+ import { computed, onBeforeUnmount, onMounted, watch } from 'vue';
3
+ import Toast from './Toast.vue';
4
+ import { toast as defaultController } from './toast.js';
5
+ import type { ToastController, ToastItem } from './types.js';
6
+ import { ensureLiveRegion } from '../../../utils/a11y/live-region.js';
7
+
8
+ interface Timer {
9
+ handle?: ReturnType<typeof setTimeout>;
10
+ startedAt: number;
11
+ remaining: number;
12
+ autoDismiss: boolean;
13
+ hovered: boolean;
14
+ focused: boolean;
15
+ }
16
+
17
+ const props = withDefaults(
18
+ defineProps<{
19
+ controller?: ToastController;
20
+ label?: string;
21
+ }>(),
22
+ {
23
+ controller: () => defaultController,
24
+ label: 'Notifications',
25
+ },
26
+ );
27
+ const items = computed(() => props.controller.visible.value);
28
+ const position = computed(() => props.controller.position.value);
29
+ const timers = new Map<string, Timer>();
30
+
31
+ function start(item: ToastItem, remaining = item.duration): void {
32
+ const timer: Timer = {
33
+ startedAt: Date.now(),
34
+ remaining,
35
+ autoDismiss: item.duration > 0,
36
+ hovered: false,
37
+ focused: false,
38
+ };
39
+ if (remaining > 0)
40
+ timer.handle = setTimeout(
41
+ () => props.controller.dismiss(item.id),
42
+ remaining,
43
+ );
44
+ timers.set(item.id, timer);
45
+ }
46
+
47
+ function pause(id: string): void {
48
+ const timer = timers.get(id);
49
+ if (!timer || timer.handle === undefined) return;
50
+ clearTimeout(timer.handle);
51
+ timer.remaining = Math.max(
52
+ 0,
53
+ timer.remaining - (Date.now() - timer.startedAt),
54
+ );
55
+ timer.handle = undefined;
56
+ }
57
+
58
+ function resume(item: ToastItem): void {
59
+ const timer = timers.get(item.id);
60
+ if (
61
+ !timer ||
62
+ timer.handle !== undefined ||
63
+ !timer.autoDismiss ||
64
+ timer.hovered ||
65
+ timer.focused
66
+ )
67
+ return;
68
+ if (timer.remaining <= 0) {
69
+ props.controller.dismiss(item.id);
70
+ return;
71
+ }
72
+ timer.startedAt = Date.now();
73
+ timer.handle = setTimeout(
74
+ () => props.controller.dismiss(item.id),
75
+ timer.remaining,
76
+ );
77
+ }
78
+
79
+ function setHovered(item: ToastItem, hovered: boolean): void {
80
+ const timer = timers.get(item.id);
81
+ if (!timer) return;
82
+ timer.hovered = hovered;
83
+ if (hovered) pause(item.id);
84
+ else resume(item);
85
+ }
86
+
87
+ function setFocused(item: ToastItem, focused: boolean): void {
88
+ const timer = timers.get(item.id);
89
+ if (!timer) return;
90
+ timer.focused = focused;
91
+ if (focused) pause(item.id);
92
+ else resume(item);
93
+ }
94
+
95
+ function onFocusOut(item: ToastItem, event: FocusEvent): void {
96
+ const root = event.currentTarget as HTMLElement;
97
+ const target = event.relatedTarget;
98
+ setFocused(item, target instanceof Node && root.contains(target));
99
+ }
100
+
101
+ function dismiss(id: string): void {
102
+ props.controller.dismiss(id);
103
+ }
104
+
105
+ function runAction(item: ToastItem): void {
106
+ try {
107
+ item.action?.onClick();
108
+ } finally {
109
+ dismiss(item.id);
110
+ }
111
+ }
112
+
113
+ watch(
114
+ items,
115
+ (next) => {
116
+ const ids = new Set(next.map((item) => item.id));
117
+ for (const [id, timer] of timers) {
118
+ if (ids.has(id)) continue;
119
+ if (timer.handle !== undefined) clearTimeout(timer.handle);
120
+ timers.delete(id);
121
+ }
122
+ for (const item of next) if (!timers.has(item.id)) start(item);
123
+ },
124
+ { immediate: true },
125
+ );
126
+
127
+ onMounted(ensureLiveRegion);
128
+
129
+ onBeforeUnmount(() => {
130
+ for (const timer of timers.values())
131
+ if (timer.handle !== undefined) clearTimeout(timer.handle);
132
+ timers.clear();
133
+ });
134
+ </script>
135
+
136
+ <template>
137
+ <Teleport to="body">
138
+ <div
139
+ v-if="items.length"
140
+ class="pui-toast-region"
141
+ role="region"
142
+ :aria-label="label"
143
+ :data-position="position"
144
+ >
145
+ <Toast
146
+ v-for="item in items"
147
+ :key="item.id"
148
+ :tone="item.tone"
149
+ :title="item.title"
150
+ :message="item.message"
151
+ :dismissible="item.dismissible"
152
+ :icon="item.icon"
153
+ :action="item.action"
154
+ aria-keyshortcuts="Escape"
155
+ @dismiss="dismiss(item.id)"
156
+ @action="runAction(item)"
157
+ @keydown.escape.prevent="dismiss(item.id)"
158
+ @mouseenter="setHovered(item, true)"
159
+ @mouseleave="setHovered(item, false)"
160
+ @focusin="setFocused(item, true)"
161
+ @focusout="onFocusOut(item, $event)"
162
+ />
163
+ </div>
164
+ </Teleport>
165
+ </template>
@@ -0,0 +1,116 @@
1
+ import { readonly, shallowRef, ref } from 'vue';
2
+ import { announce } from '../../../utils/a11y/live-region.js';
3
+ import type {
4
+ ToastConfig,
5
+ ToastController,
6
+ ToastItem,
7
+ ToastPosition,
8
+ } from './types.js';
9
+
10
+ const DEFAULT_CONFIG = {
11
+ maxVisible: 3,
12
+ defaultDuration: 5000,
13
+ position: 'bottom-right' as ToastPosition,
14
+ };
15
+
16
+ export function createToastController(
17
+ initialConfig: ToastConfig = {},
18
+ ): ToastController {
19
+ const config = { ...DEFAULT_CONFIG };
20
+ const visible = shallowRef<ToastItem[]>([]);
21
+ const position = ref<ToastPosition>(config.position);
22
+ const queued: ToastItem[] = [];
23
+ let nextId = 0;
24
+
25
+ function configure(next: ToastConfig): void {
26
+ if (
27
+ next.maxVisible !== undefined &&
28
+ Number.isFinite(next.maxVisible) &&
29
+ next.maxVisible > 0
30
+ )
31
+ config.maxVisible = Math.floor(next.maxVisible);
32
+ if (
33
+ next.defaultDuration !== undefined &&
34
+ Number.isFinite(next.defaultDuration) &&
35
+ next.defaultDuration >= 0
36
+ )
37
+ config.defaultDuration = next.defaultDuration;
38
+ if (next.position) {
39
+ config.position = next.position;
40
+ position.value = next.position;
41
+ }
42
+ if (visible.value.length > config.maxVisible) {
43
+ const overflow = visible.value.slice(config.maxVisible);
44
+ visible.value = visible.value.slice(0, config.maxVisible);
45
+ queued.unshift(...overflow);
46
+ }
47
+ drain();
48
+ }
49
+
50
+ function generateId(): string {
51
+ nextId += 1;
52
+ return `pui-toast-${Date.now().toString(36)}-${nextId}`;
53
+ }
54
+
55
+ function drain(): void {
56
+ if (visible.value.length >= config.maxVisible || !queued.length) return;
57
+ const slots = config.maxVisible - visible.value.length;
58
+ visible.value = [...visible.value, ...queued.splice(0, slots)];
59
+ }
60
+
61
+ function dismiss(id: string): void {
62
+ const next = visible.value.filter((item) => item.id !== id);
63
+ if (next.length !== visible.value.length) {
64
+ visible.value = next;
65
+ drain();
66
+ return;
67
+ }
68
+ const queuedIndex = queued.findIndex((item) => item.id === id);
69
+ if (queuedIndex >= 0) queued.splice(queuedIndex, 1);
70
+ }
71
+
72
+ configure(initialConfig);
73
+
74
+ return {
75
+ visible: readonly(visible),
76
+ position: readonly(position),
77
+ show(options) {
78
+ const requestedDuration =
79
+ options.duration ?? config.defaultDuration;
80
+ const item: ToastItem = {
81
+ ...options,
82
+ id: options.id ?? generateId(),
83
+ duration: Number.isFinite(requestedDuration)
84
+ ? Math.max(0, requestedDuration)
85
+ : config.defaultDuration,
86
+ dismissible: options.dismissible ?? true,
87
+ };
88
+ if (visible.value.length < config.maxVisible)
89
+ visible.value = [...visible.value, item];
90
+ else queued.push(item);
91
+ if (typeof document !== 'undefined')
92
+ announce(
93
+ [item.title, item.message].filter(Boolean).join('. '),
94
+ item.tone === 'danger' ? 'assertive' : 'polite',
95
+ );
96
+ return item.id;
97
+ },
98
+ dismiss,
99
+ dismissAll() {
100
+ queued.length = 0;
101
+ visible.value = [];
102
+ },
103
+ configure,
104
+ };
105
+ }
106
+
107
+ export const toast = createToastController();
108
+
109
+ export type {
110
+ ToastConfig,
111
+ ToastController,
112
+ ToastItem,
113
+ ToastOptions,
114
+ ToastPosition,
115
+ ToastTone,
116
+ } from './types.js';
@@ -0,0 +1,48 @@
1
+ import type { Ref } from 'vue';
2
+ import type { IconName } from '../../../icons/index.js';
3
+
4
+ export type ToastTone = 'brand' | 'success' | 'warning' | 'danger';
5
+
6
+ export type ToastPosition =
7
+ | 'bottom-right'
8
+ | 'bottom-center'
9
+ | 'bottom-left'
10
+ | 'top-right'
11
+ | 'top-center'
12
+ | 'top-left';
13
+
14
+ export interface ToastOptions {
15
+ title: string;
16
+ message?: string;
17
+ tone?: ToastTone;
18
+ /** Milliseconds. `0` keeps the toast visible until it is dismissed. */
19
+ duration?: number;
20
+ dismissible?: boolean;
21
+ action?: { label: string; onClick: () => void };
22
+ icon?: IconName;
23
+ id?: string;
24
+ }
25
+
26
+ export interface ToastConfig {
27
+ maxVisible?: number;
28
+ defaultDuration?: number;
29
+ position?: ToastPosition;
30
+ }
31
+
32
+ export interface ToastItem extends Omit<
33
+ ToastOptions,
34
+ 'duration' | 'dismissible' | 'id'
35
+ > {
36
+ id: string;
37
+ duration: number;
38
+ dismissible: boolean;
39
+ }
40
+
41
+ export interface ToastController {
42
+ readonly visible: Readonly<Ref<readonly ToastItem[]>>;
43
+ readonly position: Readonly<Ref<ToastPosition>>;
44
+ show(options: ToastOptions): string;
45
+ dismiss(id: string): void;
46
+ dismissAll(): void;
47
+ configure(config: ToastConfig): void;
48
+ }