@pienter/ui 0.7.1 → 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 (48) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/CONVENTIONS.md +47 -29
  3. package/README.md +36 -0
  4. package/components/display/record-details/record-details.css +2 -2
  5. package/components/feedback/toast/Toast.vue +3 -4
  6. package/components/feedback/toast/ToastHost.vue +165 -0
  7. package/components/feedback/toast/toast.ts +116 -0
  8. package/components/feedback/toast/types.ts +48 -0
  9. package/components/form/block-editor/BlockEditor.vue +2 -1
  10. package/components/form/checkbox/Checkbox.vue +10 -2
  11. package/components/form/combobox/Combobox.vue +11 -5
  12. package/components/form/combobox/combobox.css +1 -1
  13. package/components/form/date-input/DateInput.vue +10 -2
  14. package/components/form/date-input/date-input.css +2 -17
  15. package/components/form/form/Form.vue +10 -9
  16. package/components/form/form/form.css +2 -22
  17. package/components/form/input-otp/InputOTP.vue +1 -1
  18. package/components/form/input-otp/input-otp.css +1 -1
  19. package/components/form/label/label.css +2 -11
  20. package/components/form/number-field/NumberField.vue +10 -2
  21. package/components/form/number-field/number-field.css +3 -3
  22. package/components/form/radio-group/RadioGroup.vue +1 -1
  23. package/components/form/select/Select.vue +13 -4
  24. package/components/form/select/select.css +6 -9
  25. package/components/form/slider/Slider.vue +1 -1
  26. package/components/form/switch/Switch.vue +1 -1
  27. package/components/form/tags-input/TagsInput.vue +17 -2
  28. package/components/form/tags-input/tags-input.css +16 -12
  29. package/components/form/text-input/TextInput.vue +10 -2
  30. package/components/form/text-input/text-input.css +10 -131
  31. package/components/form/textarea/Textarea.vue +10 -2
  32. package/components/form/textarea/textarea.css +3 -19
  33. package/components/layout/app-layout/AppLayout.vue +116 -0
  34. package/components/layout/app-layout/app-layout.css +115 -0
  35. package/components/layout/index/Index.vue +31 -11
  36. package/components/layout/index/index.css +55 -12
  37. package/components/layout/index/useIndex.ts +31 -14
  38. package/components/layout/table/DataTable.vue +14 -1
  39. package/components/layout/table/Table.vue +12 -0
  40. package/components/navigation/breadcrumb/breadcrumb.css +6 -1
  41. package/components/navigation/sidebar/sidebar.css +23 -16
  42. package/components/navigation/tabs/tabs.css +7 -7
  43. package/package.json +6 -1
  44. package/styles/4-components/form-field.css +112 -0
  45. package/styles/4-components/index.css +1 -0
  46. package/styles/main.css +1 -0
  47. package/utils/a11y/index.ts +5 -1
  48. package/utils/a11y/live-region.ts +2 -1
@@ -20,7 +20,8 @@ import {
20
20
  interface Options<T> {
21
21
  load: ListLoader<T>;
22
22
  queryOptions: QueryOptions;
23
- syncQuery: boolean;
23
+ /** `true` syncs the bare keys; a string syncs `<prefix>.<key>` instead. */
24
+ syncQuery: boolean | string;
24
25
  searchDebounce: number;
25
26
  }
26
27
 
@@ -34,14 +35,21 @@ function isListParameter(key: string): boolean {
34
35
  );
35
36
  }
36
37
 
37
- function routeParams(values: LocationQuery): URLSearchParams {
38
+ function listKey(key: string, prefix: string): string | undefined {
39
+ if (!key.startsWith(prefix)) return undefined;
40
+ const bare = key.slice(prefix.length);
41
+ return isListParameter(bare) ? bare : undefined;
42
+ }
43
+
44
+ function routeParams(values: LocationQuery, prefix: string): URLSearchParams {
38
45
  const params = new URLSearchParams();
39
46
  for (const [key, valuesForKey] of Object.entries(values)) {
40
- if (!isListParameter(key)) continue;
47
+ const bare = listKey(key, prefix);
48
+ if (bare === undefined) continue;
41
49
  for (const value of Array.isArray(valuesForKey)
42
50
  ? valuesForKey
43
51
  : [valuesForKey])
44
- params.append(key, value ?? '');
52
+ params.append(bare, value ?? '');
45
53
  }
46
54
  params.sort();
47
55
  return params;
@@ -78,6 +86,11 @@ interface Work {
78
86
  export function useIndex<T>(props: Options<T>) {
79
87
  const route = inject(routeLocationKey, undefined);
80
88
  const router = inject(routerKey, undefined);
89
+ const syncing = () => props.syncQuery !== false;
90
+ const prefix = () =>
91
+ typeof props.syncQuery === 'string' && props.syncQuery
92
+ ? `${props.syncQuery}.`
93
+ : '';
81
94
  const defaults = () =>
82
95
  normalizeListQuery(
83
96
  {
@@ -173,12 +186,12 @@ export function useIndex<T>(props: Options<T>) {
173
186
  }
174
187
 
175
188
  const urlQuery = computed(() =>
176
- props.syncQuery && route ? routeParams(route.query).toString() : '',
189
+ syncing() && route ? routeParams(route.query, prefix()).toString() : '',
177
190
  );
178
191
  watch(
179
192
  [
180
193
  urlQuery,
181
- () => (props.syncQuery ? route?.path : ''),
194
+ () => (syncing() ? route?.path : ''),
182
195
  () => props.syncQuery,
183
196
  () => props.queryOptions,
184
197
  () => props.load,
@@ -197,12 +210,12 @@ export function useIndex<T>(props: Options<T>) {
197
210
  navigation++;
198
211
  if (!preserveSearch) cancelSearch();
199
212
  cancelRequest();
200
- if (props.syncQuery && (!route || !router))
213
+ if (syncing() && (!route || !router))
201
214
  throw new Error(
202
215
  'Index sync-query requires an installed Vue Router.',
203
216
  );
204
217
  try {
205
- const next = props.syncQuery
218
+ const next = syncing()
206
219
  ? decodeListQuery(
207
220
  new URLSearchParams(urlQuery.value),
208
221
  props.queryOptions,
@@ -233,18 +246,22 @@ export function useIndex<T>(props: Options<T>) {
233
246
  work.finish();
234
247
  return work.promise;
235
248
  }
236
- if (!props.syncQuery || !route || !router)
237
- return accept(normalized, work);
249
+ if (!syncing() || !route || !router) return accept(normalized, work);
250
+ const keyPrefix = prefix();
238
251
  const values = { ...route.query };
239
252
  for (const key of Object.keys(values))
240
- if (isListParameter(key)) delete values[key];
253
+ if (listKey(key, keyPrefix) !== undefined) delete values[key];
241
254
  for (const [key, value] of encodeListQuery(normalized))
242
- values[key] = value;
255
+ values[keyPrefix + key] = value;
243
256
  query.value = normalized;
244
257
  search.value = normalized.search;
245
258
  const attempt = ++navigation;
246
259
  const path = route.path;
247
- pendingUrl = { path, query: routeParams(values).toString(), work };
260
+ pendingUrl = {
261
+ path,
262
+ query: routeParams(values, keyPrefix).toString(),
263
+ work,
264
+ };
248
265
  loading.value = true;
249
266
  void (async () => {
250
267
  try {
@@ -285,7 +302,7 @@ export function useIndex<T>(props: Options<T>) {
285
302
  cancelRequest();
286
303
  try {
287
304
  query.value = decodeListQuery(
288
- routeParams(route!.query),
305
+ routeParams(route!.query, prefix()),
289
306
  props.queryOptions,
290
307
  );
291
308
  if (!preserveSearch) search.value = query.value.search;
@@ -10,6 +10,8 @@
10
10
  :selectable="selectable"
11
11
  :clickable="clickable"
12
12
  :all-selected="allSelected"
13
+ :label="label"
14
+ :labelledby="labelledby"
13
15
  @toggle-all="toggleAll"
14
16
  @sort="(key) => emit('sort', key)"
15
17
  >
@@ -26,7 +28,7 @@
26
28
  class="pui-table__check"
27
29
  type="checkbox"
28
30
  :checked="isSelected(keyOf(row))"
29
- aria-label="Select row"
31
+ :aria-label="rowLabel(row)"
30
32
  @click.stop
31
33
  @change="toggle(keyOf(row))"
32
34
  />
@@ -78,6 +80,10 @@ const props = withDefaults(
78
80
  sort?: SortState;
79
81
  /** Frosts and disables the body while the rows are stale, and suppresses the empty state. */
80
82
  loading?: boolean;
83
+ /** Accessible name, rendered as a visually hidden `<caption>`. */
84
+ label?: string;
85
+ /** `id` of a visible heading that names the table; wins over `label`. */
86
+ labelledby?: string;
81
87
  }>(),
82
88
  {
83
89
  rowKey: 'id',
@@ -86,6 +92,8 @@ const props = withDefaults(
86
92
  clickable: false,
87
93
  sort: null,
88
94
  loading: false,
95
+ label: undefined,
96
+ labelledby: undefined,
89
97
  },
90
98
  );
91
99
 
@@ -103,6 +111,11 @@ function cellValue(row: T, key: string): unknown {
103
111
  function keyOf(row: T): string | number {
104
112
  return cellValue(row, props.rowKey) as string | number;
105
113
  }
114
+ function rowLabel(row: T): string {
115
+ const first = props.columns[0];
116
+ const value = first ? String(cellValue(row, first.key) ?? '').trim() : '';
117
+ return value ? `Select ${value}` : 'Select row';
118
+ }
106
119
  function isSelected(key: string | number): boolean {
107
120
  return props.selected.includes(key);
108
121
  }
@@ -8,7 +8,13 @@
8
8
  class="pui-table"
9
9
  :style="{ '--pui-table-cols': cols }"
10
10
  :aria-busy="loading || undefined"
11
+ :aria-labelledby="labelledby"
11
12
  >
13
+ <caption v-if="label" class="u-visually-hidden">
14
+ {{
15
+ label
16
+ }}
17
+ </caption>
12
18
  <thead class="pui-table__head">
13
19
  <tr class="pui-table__row" data-head>
14
20
  <th
@@ -104,6 +110,10 @@ const props = withDefaults(
104
110
  sort?: SortState;
105
111
  /** Frosts and disables the body while the rows are stale, leaving the header usable. */
106
112
  loading?: boolean;
113
+ /** Accessible name, rendered as a visually hidden `<caption>`. */
114
+ label?: string;
115
+ /** `id` of a visible heading that names the table; wins over `label`. */
116
+ labelledby?: string;
107
117
  }>(),
108
118
  {
109
119
  selectable: false,
@@ -111,6 +121,8 @@ const props = withDefaults(
111
121
  allSelected: false,
112
122
  sort: null,
113
123
  loading: false,
124
+ label: undefined,
125
+ labelledby: undefined,
114
126
  },
115
127
  );
116
128
 
@@ -3,7 +3,7 @@
3
3
  display: flex;
4
4
  align-items: center;
5
5
  justify-content: space-between;
6
- gap: var(--space-sm);
6
+ gap: var(--space-s);
7
7
  }
8
8
 
9
9
  .pui-breadcrumb__trail {
@@ -41,6 +41,11 @@
41
41
  color: var(--text-clr-base);
42
42
  }
43
43
 
44
+ .pui-breadcrumb__link:focus-visible {
45
+ outline: var(--outline-width) solid var(--outline-clr-base);
46
+ outline-offset: var(--outline-offset);
47
+ }
48
+
44
49
  .pui-breadcrumb__sep {
45
50
  color: var(--border-clr-base);
46
51
  }
@@ -290,7 +290,9 @@
290
290
  flex-direction: column;
291
291
  justify-content: center;
292
292
  gap: var(--space-3xs);
293
- padding-inline: var(--space-3xs);
293
+ /* The panel's own inline padding already holds the focus ring; any
294
+ more and a nine-character caption ("Companies") breaks mid-word. */
295
+ padding-inline: 0;
294
296
  text-align: center;
295
297
  }
296
298
 
@@ -425,21 +427,6 @@
425
427
  padding-inline-start: var(--space-2xs);
426
428
  }
427
429
 
428
- /* It travels, so it needs its own reduced-motion escape. */
429
- @media (prefers-reduced-motion: reduce) {
430
- .pui-sidebar[data-state='collapsed']
431
- .pui-sidebar__group[data-submenu-state]
432
- > .pui-sidebar__group-items {
433
- transition: none;
434
- }
435
-
436
- .pui-sidebar[data-state='collapsed']
437
- .pui-sidebar__group[data-submenu-state='closed']
438
- > .pui-sidebar__group-items {
439
- transform: none;
440
- }
441
- }
442
-
443
430
  /* Mobile drawer mode — below the breakpoint, the column transforms into a fixed-overlay drawer */
444
431
  @media (max-width: 767px) {
445
432
  .pui-sidebar,
@@ -568,4 +555,24 @@
568
555
  background: transparent;
569
556
  }
570
557
  }
558
+
559
+ /* Everything that travels or fades. After the drawer block: a media query
560
+ grants no extra specificity, so order decides. */
561
+ @media (prefers-reduced-motion: reduce) {
562
+ .pui-sidebar,
563
+ .pui-sidebar__panel,
564
+ .pui-sidebar__backdrop,
565
+ .pui-sidebar__group-caret,
566
+ .pui-sidebar[data-state='collapsed']
567
+ .pui-sidebar__group[data-submenu-state]
568
+ > .pui-sidebar__group-items {
569
+ transition: none;
570
+ }
571
+
572
+ .pui-sidebar[data-state='collapsed']
573
+ .pui-sidebar__group[data-submenu-state='closed']
574
+ > .pui-sidebar__group-items {
575
+ transform: none;
576
+ }
577
+ }
571
578
  }
@@ -2,7 +2,7 @@
2
2
  .pui-tabs {
3
3
  display: flex;
4
4
  gap: var(--space-s);
5
- border-block-end: var(--stroke-sm) solid var(--border-clr-base);
5
+ border-block-end: var(--stroke-sm) solid var(--border-clr-subtle);
6
6
  }
7
7
  .pui-tabs__item {
8
8
  appearance: none;
@@ -14,9 +14,9 @@
14
14
  font-weight: var(--fw-semibold);
15
15
  color: var(--text-clr-muted);
16
16
  cursor: pointer;
17
- border-block-end: 2px solid transparent;
18
- margin-block-end: -1px;
19
- transition: color 0.12s ease;
17
+ border-block-end: var(--stroke-lg) solid transparent;
18
+ margin-block-end: calc(-1 * var(--stroke-sm));
19
+ transition: color var(--duration-fast) var(--ease-base);
20
20
  }
21
21
  .pui-tabs__item[aria-selected='true'] {
22
22
  color: var(--text-clr-base);
@@ -26,11 +26,11 @@
26
26
  color: var(--text-clr-base);
27
27
  }
28
28
  .pui-tabs__item:focus-visible {
29
- outline: 3px solid var(--outline-clr-base);
30
- outline-offset: 2px;
29
+ outline: var(--outline-width) solid var(--outline-clr-base);
30
+ outline-offset: var(--outline-offset);
31
31
  }
32
32
  .pui-tabs__item:disabled {
33
- opacity: 0.4;
33
+ opacity: var(--opacity-disabled);
34
34
  cursor: not-allowed;
35
35
  }
36
36
  .pui-tabs__item:disabled:hover {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pienter/ui",
3
- "version": "0.7.1",
3
+ "version": "0.8.0",
4
4
  "description": "Shared Pienter UI components, styles, icons, and browser utilities.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -41,6 +41,8 @@
41
41
  "./components/Skeleton.vue": "./components/feedback/skeleton/Skeleton.vue",
42
42
  "./components/Spinner.vue": "./components/feedback/spinner/Spinner.vue",
43
43
  "./components/Toast.vue": "./components/feedback/toast/Toast.vue",
44
+ "./components/ToastHost.vue": "./components/feedback/toast/ToastHost.vue",
45
+ "./components/Toast.types": "./components/feedback/toast/types.ts",
44
46
  "./components/Checkbox.vue": "./components/form/checkbox/Checkbox.vue",
45
47
  "./components/Combobox.vue": "./components/form/combobox/Combobox.vue",
46
48
  "./components/DateInput.vue": "./components/form/date-input/DateInput.vue",
@@ -60,6 +62,7 @@
60
62
  "./components/Accordion.vue": "./components/layout/accordion/Accordion.vue",
61
63
  "./components/Card.vue": "./components/layout/card/Card.vue",
62
64
  "./components/Collapsible.vue": "./components/layout/collapsible/Collapsible.vue",
65
+ "./components/AppLayout.vue": "./components/layout/app-layout/AppLayout.vue",
63
66
  "./components/Separator.vue": "./components/layout/separator/Separator.vue",
64
67
  "./components/Table.vue": "./components/layout/table/Table.vue",
65
68
  "./components/TableRow.vue": "./components/layout/table/TableRow.vue",
@@ -88,6 +91,8 @@
88
91
  "./icons/*": "./icons/*",
89
92
  "./utils": "./utils/index.ts",
90
93
  "./utils/*": "./utils/*",
94
+ "./toast": "./components/feedback/toast/toast.ts",
95
+ "./utils/a11y": "./utils/a11y/index.ts",
91
96
  "./components/Index.vue": "./components/layout/index/Index.vue",
92
97
  "./components/RecordForm.vue": "./components/form/record-form/RecordForm.vue",
93
98
  "./components/RecordForm.types": "./components/form/record-form/types.ts",
@@ -0,0 +1,112 @@
1
+ @layer components {
2
+ /* The form-primitive scaffold shared by every form control: the
3
+ * `.pui-field` wrapper, its label / hint / required marker, and the
4
+ * `.pui-input` / `.pui-textarea` control surface. Loaded with the
5
+ * styles entry so it does not depend on which component is on the page. */
6
+
7
+ .pui-field {
8
+ display: grid;
9
+ align-content: start;
10
+ gap: var(--space-2xs);
11
+ }
12
+
13
+ /* Zero the UA fieldset chrome (groove border, padding, and the
14
+ * `min-inline-size: min-content` that stops it shrinking) so a
15
+ * grouped control can host the grid scaffold. */
16
+ .pui-field:where(fieldset) {
17
+ border: 0;
18
+ padding: 0;
19
+ margin: 0;
20
+ min-inline-size: 0;
21
+ }
22
+
23
+ /* A legend floats and pads itself inside a grid fieldset; reset it to
24
+ * an ordinary grid item so the row gap governs label spacing. */
25
+ .pui-field__label:where(legend) {
26
+ padding: 0;
27
+ float: none;
28
+ }
29
+
30
+ /* `data-layout="inline"`: control first in DOM order, label beside it,
31
+ * hint and errors below spanning both columns. */
32
+ .pui-field[data-layout='inline'] {
33
+ grid-template-columns: max-content 1fr;
34
+ column-gap: var(--space-xs);
35
+ align-items: center;
36
+ }
37
+
38
+ .pui-field[data-layout='inline'] .pui-field__label {
39
+ cursor: pointer;
40
+ user-select: none;
41
+ }
42
+
43
+ .pui-field[data-layout='inline'] .pui-field__hint {
44
+ grid-column: 1 / -1;
45
+ }
46
+
47
+ .pui-field__label {
48
+ margin-block: 0;
49
+ font-size: var(--step--1);
50
+ font-weight: var(--fw-bold);
51
+ }
52
+
53
+ .pui-field__required {
54
+ margin-inline-start: var(--space-3xs);
55
+ color: var(--text-clr-danger);
56
+ }
57
+
58
+ .pui-field__hint {
59
+ font-size: var(--step--2);
60
+ color: var(--text-clr-muted);
61
+ margin: 0;
62
+ }
63
+
64
+ .pui-field[data-status='error'] .pui-field__hint {
65
+ color: var(--text-clr-danger);
66
+ }
67
+
68
+ .pui-field[data-status='success'] .pui-field__hint {
69
+ color: var(--text-clr-success);
70
+ }
71
+
72
+ .pui-input,
73
+ .pui-textarea {
74
+ width: 100%;
75
+ padding: 0.65em 0.85em;
76
+ font: inherit;
77
+ background: var(--bg-clr-surface);
78
+ border: var(--stroke-sm) solid var(--border-clr-input);
79
+ border-radius: var(--radius-sm);
80
+ color: var(--text-clr-base);
81
+ transition: border-color var(--duration-fast) var(--ease-base);
82
+ }
83
+
84
+ .pui-textarea {
85
+ min-height: 6rem;
86
+ resize: vertical;
87
+ }
88
+
89
+ .pui-input:focus-visible,
90
+ .pui-textarea:focus-visible {
91
+ border-color: var(--border-clr-brand);
92
+ outline: var(--outline-width) solid var(--outline-clr-base);
93
+ outline-offset: var(--outline-offset);
94
+ }
95
+
96
+ .pui-input:disabled,
97
+ .pui-textarea:disabled {
98
+ background: var(--bg-clr-surface-2);
99
+ color: var(--text-clr-muted);
100
+ cursor: not-allowed;
101
+ }
102
+
103
+ .pui-field[data-status='error'] .pui-input,
104
+ .pui-field[data-status='error'] .pui-textarea {
105
+ border-color: var(--border-clr-danger);
106
+ }
107
+
108
+ .pui-field[data-status='success'] .pui-input,
109
+ .pui-field[data-status='success'] .pui-textarea {
110
+ border-color: var(--border-clr-success);
111
+ }
112
+ }
@@ -0,0 +1 @@
1
+ @import url('./form-field.css');
package/styles/main.css CHANGED
@@ -5,4 +5,5 @@
5
5
  @import url('./1-reset/index.css');
6
6
  @import url('./2-base/index.css');
7
7
  @import url('./3-layout/index.css');
8
+ @import url('./4-components/index.css');
8
9
  @import url('./5-utilities/index.css');
@@ -6,4 +6,8 @@ export {
6
6
  type FocusTrap,
7
7
  } from './focus.js';
8
8
  export { onKeyboardNav, type KeyboardNavHandlers } from './keyboard.js';
9
- export { announce, type LiveRegionPriority } from './live-region.js';
9
+ export {
10
+ announce,
11
+ ensureLiveRegion,
12
+ type LiveRegionPriority,
13
+ } from './live-region.js';
@@ -2,7 +2,8 @@ export type LiveRegionPriority = 'polite' | 'assertive';
2
2
 
3
3
  const REGION_ID = 'pienter-ui-live-region';
4
4
 
5
- function ensureLiveRegion(): HTMLElement {
5
+ /** Creates the shared live region if missing; call on mount so the first `announce()` is not dropped. */
6
+ export function ensureLiveRegion(): HTMLElement {
6
7
  let region = document.getElementById(REGION_ID);
7
8
  if (!region) {
8
9
  region = document.createElement('div');