@pienter/ui 0.10.0 → 0.13.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 (36) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/CONVENTIONS.md +33 -21
  3. package/components/display/badge/badge.css +7 -0
  4. package/components/form/block-editor/BlockEditor.vue +61 -44
  5. package/components/form/block-editor/block-editor.css +11 -0
  6. package/components/form/checkbox/Checkbox.vue +23 -7
  7. package/components/form/checkbox/checkbox.css +18 -0
  8. package/components/form/combobox/Combobox.vue +18 -3
  9. package/components/form/date-input/DateInput.vue +13 -1
  10. package/components/form/form/Form.vue +5 -2
  11. package/components/form/number-field/NumberField.vue +16 -4
  12. package/components/form/number-field/number-field.css +6 -0
  13. package/components/form/radio-group/RadioGroup.vue +23 -1
  14. package/components/form/radio-group/radio-group.css +9 -0
  15. package/components/form/record-form/RecordFields.vue +40 -2
  16. package/components/form/record-form/RecordForm.vue +7 -1
  17. package/components/form/record-form/record-form.css +14 -0
  18. package/components/form/record-form/types.ts +6 -0
  19. package/components/form/select/Select.vue +40 -7
  20. package/components/form/select/select.css +5 -0
  21. package/components/form/switch/Switch.vue +23 -7
  22. package/components/form/switch/switch.css +14 -0
  23. package/components/form/tags-input/TagsInput.vue +19 -6
  24. package/components/form/tags-input/tags-input.css +5 -0
  25. package/components/form/text-input/TextInput.vue +14 -2
  26. package/components/form/textarea/Textarea.vue +14 -2
  27. package/components/form/textarea/textarea.css +7 -0
  28. package/components/layout/index/Index.vue +1 -0
  29. package/components/layout/index/index.css +8 -7
  30. package/components/layout/record-layout/Panel.vue +44 -0
  31. package/components/layout/record-layout/RecordLayout.vue +35 -0
  32. package/components/layout/record-layout/record-layout.css +94 -0
  33. package/components/navigation/tabs/tabs.css +2 -0
  34. package/package.json +3 -1
  35. package/styles/2-base/base.css +6 -1
  36. package/styles/4-components/form-field.css +10 -7
@@ -1,5 +1,10 @@
1
1
  <template>
2
- <div class="pui-field" :data-status="computedStatus">
2
+ <div
3
+ class="pui-field"
4
+ :data-layout="layout === 'inline' ? 'inline' : undefined"
5
+ :data-readonly="readonly ? 'true' : undefined"
6
+ :data-status="computedStatus"
7
+ >
3
8
  <label class="pui-field__label" :for="inputId"
4
9
  >{{ label
5
10
  }}<span
@@ -31,6 +36,7 @@
31
36
  >
32
37
  <span class="pui-tags-input__tag-label">{{ tag }}</span>
33
38
  <button
39
+ v-if="!readonly"
34
40
  type="button"
35
41
  class="pui-tags-input__tag-remove"
36
42
  :aria-label="`Remove tag ${tag}`"
@@ -48,8 +54,9 @@
48
54
  class="pui-tags-input__entry"
49
55
  type="text"
50
56
  :name="name"
51
- :placeholder="placeholder"
52
- :disabled="disabled || maxReached"
57
+ :placeholder="readonly ? undefined : placeholder"
58
+ :disabled="disabled || (!readonly && maxReached)"
59
+ :readonly="readonly"
53
60
  :aria-required="required ? 'true' : undefined"
54
61
  :aria-invalid="hasErrors ? 'true' : undefined"
55
62
  :aria-describedby="describedBy"
@@ -87,6 +94,8 @@ const props = withDefaults(
87
94
  /** Marks the field; set as `aria-required` on the entry input since the value is the tag list, not the typed text. */
88
95
  required?: boolean;
89
96
  disabled?: boolean;
97
+ /** Read-only: tags show without remove buttons and the entry is a focusable native `readonly` input. */
98
+ readonly?: boolean;
90
99
  hint?: string;
91
100
  errors?: string[];
92
101
  status?: 'error' | 'success';
@@ -100,6 +109,8 @@ const props = withDefaults(
100
109
  * `[',', 'Enter']`.
101
110
  */
102
111
  separators?: string[];
112
+ /** `inline` puts the label beside the control; hint and errors wrap below. */
113
+ layout?: 'stacked' | 'inline';
103
114
  }>(),
104
115
  {
105
116
  id: undefined,
@@ -107,11 +118,13 @@ const props = withDefaults(
107
118
  placeholder: undefined,
108
119
  required: false,
109
120
  disabled: false,
121
+ readonly: false,
110
122
  hint: undefined,
111
123
  errors: () => [],
112
124
  status: undefined,
113
125
  maxTags: undefined,
114
126
  separators: () => [',', 'Enter'],
127
+ layout: 'stacked',
115
128
  },
116
129
  );
117
130
 
@@ -156,7 +169,7 @@ function isSeparator(event: KeyboardEvent): boolean {
156
169
 
157
170
  function addTag(raw: string): boolean {
158
171
  const value = raw.trim();
159
- if (!value) return false;
172
+ if (!value || props.readonly) return false;
160
173
  if (maxReached.value) return false;
161
174
  if (props.modelValue.includes(value)) return false;
162
175
  emit('update:modelValue', [...props.modelValue, value]);
@@ -164,7 +177,7 @@ function addTag(raw: string): boolean {
164
177
  }
165
178
 
166
179
  function removeTag(index: number): void {
167
- if (props.disabled) return;
180
+ if (props.disabled || props.readonly) return;
168
181
  if (index < 0 || index >= props.modelValue.length) return;
169
182
  const tag = props.modelValue[index];
170
183
  const next = props.modelValue.filter((_, i) => i !== index);
@@ -175,7 +188,7 @@ function removeTag(index: number): void {
175
188
  }
176
189
 
177
190
  function onKeydown(event: KeyboardEvent): void {
178
- if (props.disabled) return;
191
+ if (props.disabled || props.readonly) return;
179
192
  if (isSeparator(event)) {
180
193
  if (entryText.value.trim().length > 0) {
181
194
  event.preventDefault();
@@ -47,6 +47,11 @@
47
47
  cursor: not-allowed;
48
48
  }
49
49
 
50
+ .pui-field[data-readonly='true'] .pui-tags-input {
51
+ background: var(--bg-clr-surface-2);
52
+ cursor: default;
53
+ }
54
+
50
55
  /* Status states inherited from .pui-field — recolour to match
51
56
  * .pui-input / .pui-textarea when the parent carries `data-status`. */
52
57
  .pui-field[data-status='error'] .pui-tags-input {
@@ -1,5 +1,10 @@
1
1
  <template>
2
- <div class="pui-field" :data-status="computedStatus">
2
+ <div
3
+ class="pui-field"
4
+ :data-layout="layout === 'inline' ? 'inline' : undefined"
5
+ :data-readonly="readonly ? 'true' : undefined"
6
+ :data-status="computedStatus"
7
+ >
3
8
  <label class="pui-field__label" :for="inputId"
4
9
  >{{ label
5
10
  }}<span
@@ -19,7 +24,8 @@
19
24
  :autocomplete="autocomplete"
20
25
  :required="required"
21
26
  :disabled="disabled"
22
- :placeholder="placeholder"
27
+ :readonly="readonly"
28
+ :placeholder="readonly ? undefined : placeholder"
23
29
  :aria-invalid="hasErrors ? 'true' : undefined"
24
30
  :aria-describedby="describedBy"
25
31
  @input="
@@ -58,10 +64,14 @@ const props = withDefaults(
58
64
  autocomplete?: string;
59
65
  required?: boolean;
60
66
  disabled?: boolean;
67
+ /** Read-only: the value shows in the field chrome, stays focusable and cannot be edited. */
68
+ readonly?: boolean;
61
69
  placeholder?: string;
62
70
  hint?: string;
63
71
  errors?: string[];
64
72
  status?: 'error' | 'success';
73
+ /** `inline` puts the label beside the control; hint and errors wrap below. */
74
+ layout?: 'stacked' | 'inline';
65
75
  }>(),
66
76
  {
67
77
  id: undefined,
@@ -70,10 +80,12 @@ const props = withDefaults(
70
80
  autocomplete: undefined,
71
81
  required: false,
72
82
  disabled: false,
83
+ readonly: false,
73
84
  placeholder: undefined,
74
85
  hint: undefined,
75
86
  errors: () => [],
76
87
  status: undefined,
88
+ layout: 'stacked',
77
89
  },
78
90
  );
79
91
 
@@ -1,5 +1,10 @@
1
1
  <template>
2
- <div class="pui-field" :data-status="computedStatus">
2
+ <div
3
+ class="pui-field"
4
+ :data-layout="layout === 'inline' ? 'inline' : undefined"
5
+ :data-readonly="readonly ? 'true' : undefined"
6
+ :data-status="computedStatus"
7
+ >
3
8
  <label class="pui-field__label" :for="inputId"
4
9
  >{{ label
5
10
  }}<span
@@ -18,7 +23,8 @@
18
23
  :autocomplete="autocomplete"
19
24
  :required="required"
20
25
  :disabled="disabled"
21
- :placeholder="placeholder"
26
+ :readonly="readonly"
27
+ :placeholder="readonly ? undefined : placeholder"
22
28
  :style="autoResize ? 'field-sizing: content' : undefined"
23
29
  :aria-invalid="hasErrors ? 'true' : undefined"
24
30
  :aria-describedby="describedBy"
@@ -59,11 +65,15 @@ const props = withDefaults(
59
65
  autocomplete?: string;
60
66
  required?: boolean;
61
67
  disabled?: boolean;
68
+ /** Read-only: the value shows in the field chrome, stays focusable and cannot be edited. */
69
+ readonly?: boolean;
62
70
  placeholder?: string;
63
71
  hint?: string;
64
72
  errors?: string[];
65
73
  status?: 'error' | 'success';
66
74
  autoResize?: boolean;
75
+ /** `inline` puts the label beside the control; hint and errors wrap below. */
76
+ layout?: 'stacked' | 'inline';
67
77
  }>(),
68
78
  {
69
79
  id: undefined,
@@ -72,11 +82,13 @@ const props = withDefaults(
72
82
  autocomplete: undefined,
73
83
  required: false,
74
84
  disabled: false,
85
+ readonly: false,
75
86
  placeholder: undefined,
76
87
  hint: undefined,
77
88
  errors: () => [],
78
89
  status: undefined,
79
90
  autoResize: false,
91
+ layout: 'stacked',
80
92
  },
81
93
  );
82
94
 
@@ -6,4 +6,11 @@
6
6
  .pui-textarea[style*='field-sizing'] {
7
7
  resize: none;
8
8
  }
9
+
10
+ /* Read-only: `rows` becomes a minimum and the box grows, so a long value
11
+ * is read in place rather than scrolled inside a box nobody can type in. */
12
+ .pui-field[data-readonly='true'] .pui-textarea {
13
+ field-sizing: content;
14
+ resize: none;
15
+ }
9
16
  }
@@ -140,6 +140,7 @@
140
140
  <Select
141
141
  :label="pageSizeLabel"
142
142
  :aria-label="pageSizeLabel"
143
+ layout="inline"
143
144
  :model-value="String(query.page_size)"
144
145
  :options="pageSizeOptions"
145
146
  @update:model-value="
@@ -47,14 +47,15 @@
47
47
  --pui-table-cell-padding-block: var(--space-2xs);
48
48
  }
49
49
 
50
- .pui-index__page-size > .pui-field {
51
- display: flex;
52
- align-items: center;
53
- gap: var(--space-xs);
50
+ /* Two-line primary cell: title (usually a link) over a muted subline. */
51
+ .pui-index__primary {
52
+ display: grid;
53
+ gap: var(--space-3xs);
54
+ }
54
55
 
55
- & > .pui-field__label {
56
- white-space: nowrap;
57
- }
56
+ .pui-index__subline {
57
+ color: var(--text-clr-muted);
58
+ font-size: var(--step--2);
58
59
  }
59
60
 
60
61
  .pui-index__pagination {
@@ -0,0 +1,44 @@
1
+ <template>
2
+ <section class="pui-panel">
3
+ <header v-if="title || description" class="pui-panel__header">
4
+ <component
5
+ :is="`h${level}`"
6
+ v-if="title"
7
+ :id="titleId"
8
+ class="pui-panel__title"
9
+ >{{ title }}</component
10
+ >
11
+ <p v-if="description" class="pui-panel__description">
12
+ {{ description }}
13
+ </p>
14
+ </header>
15
+ <slot />
16
+ </section>
17
+ </template>
18
+
19
+ <script setup lang="ts">
20
+ withDefaults(
21
+ defineProps<{
22
+ title?: string;
23
+ description?: string;
24
+ /** Heading level of the title: `2` under a page heading, `3` under another section. */
25
+ level?: 2 | 3;
26
+ /** `id` for the title element, so the panel or a landmark can reference it through `aria-labelledby`. */
27
+ titleId?: string;
28
+ }>(),
29
+ {
30
+ title: undefined,
31
+ description: undefined,
32
+ level: 2,
33
+ titleId: undefined,
34
+ },
35
+ );
36
+
37
+ defineSlots<{
38
+ default?: () => unknown;
39
+ }>();
40
+ </script>
41
+
42
+ <style>
43
+ @import './record-layout.css';
44
+ </style>
@@ -0,0 +1,35 @@
1
+ <template>
2
+ <div class="pui-record-layout">
3
+ <aside
4
+ v-if="$slots.sidebar"
5
+ class="pui-record-layout__sidebar"
6
+ :aria-labelledby="sidebarLabelledby"
7
+ >
8
+ <slot name="sidebar" />
9
+ </aside>
10
+ <div class="pui-record-layout__main">
11
+ <slot />
12
+ </div>
13
+ </div>
14
+ </template>
15
+
16
+ <script setup lang="ts">
17
+ withDefaults(
18
+ defineProps<{
19
+ /** `id` of the sidebar's heading; names the `<aside>` landmark through `aria-labelledby`. */
20
+ sidebarLabelledby?: string;
21
+ }>(),
22
+ {
23
+ sidebarLabelledby: undefined,
24
+ },
25
+ );
26
+
27
+ defineSlots<{
28
+ sidebar?: () => unknown;
29
+ default?: () => unknown;
30
+ }>();
31
+ </script>
32
+
33
+ <style>
34
+ @import './record-layout.css';
35
+ </style>
@@ -0,0 +1,94 @@
1
+ @layer components {
2
+ .pui-record-layout {
3
+ display: grid;
4
+ grid-template-columns:
5
+ minmax(0, var(--space-sidebar-width))
6
+ minmax(0, 1fr);
7
+ gap: var(--space-m);
8
+ align-items: start;
9
+ min-inline-size: 0;
10
+
11
+ @media (max-width: 56em) {
12
+ grid-template-columns: minmax(0, 1fr);
13
+ }
14
+ }
15
+
16
+ .pui-record-layout__sidebar {
17
+ min-inline-size: 0;
18
+
19
+ & .pui-panel {
20
+ --pui-record-fields-columns: minmax(0, 1fr);
21
+ }
22
+
23
+ /* Same layer as record-details.css, so this has to outrank its field rule. */
24
+ & .pui-record-details .pui-record-details__field {
25
+ grid-template-columns: minmax(0, 1fr);
26
+ gap: var(--space-3xs);
27
+ }
28
+
29
+ @media (min-width: 24em) and (max-width: 56em) {
30
+ & .pui-panel {
31
+ --pui-record-fields-columns: repeat(2, minmax(0, 1fr));
32
+ }
33
+ }
34
+ }
35
+
36
+ .pui-record-layout__main {
37
+ display: grid;
38
+ gap: var(--space-s);
39
+ min-inline-size: 0;
40
+ }
41
+
42
+ .pui-panel {
43
+ --pui-record-fields-columns: minmax(0, 1fr);
44
+
45
+ display: grid;
46
+ gap: var(--space-s);
47
+ min-inline-size: 0;
48
+ padding: var(--space-m);
49
+ border: var(--stroke-sm) solid var(--border-clr-subtle);
50
+ border-radius: var(--radius-md);
51
+ background: var(--bg-clr-surface);
52
+
53
+ &[hidden] {
54
+ display: none;
55
+ }
56
+
57
+ & .pui-field:has(textarea) {
58
+ grid-column: 1 / -1;
59
+ }
60
+
61
+ /* An editable block list draws its own boxes and needs the width for
62
+ * its controls; a read-only one is content and keeps the panel. */
63
+ &:has(> .pui-block-editor:not([data-readonly='true'])) {
64
+ padding: var(--space-2xs) 0 0;
65
+ border: 0;
66
+ background: transparent;
67
+ }
68
+
69
+ @media (min-width: 48em) {
70
+ --pui-record-fields-columns: repeat(2, minmax(0, 1fr));
71
+ }
72
+
73
+ @media (max-width: 56em) {
74
+ padding: var(--space-s);
75
+ }
76
+ }
77
+
78
+ .pui-panel__header {
79
+ display: grid;
80
+ gap: var(--space-3xs);
81
+ }
82
+
83
+ .pui-panel__title {
84
+ margin: 0;
85
+ font-size: var(--step-0);
86
+ font-weight: var(--fw-semibold);
87
+ }
88
+
89
+ .pui-panel__description {
90
+ margin: 0;
91
+ font-size: var(--step--1);
92
+ color: var(--text-clr-muted);
93
+ }
94
+ }
@@ -1,7 +1,9 @@
1
1
  @layer components {
2
2
  .pui-tabs {
3
3
  display: flex;
4
+ flex-wrap: wrap;
4
5
  gap: var(--space-s);
6
+ min-inline-size: 0;
5
7
  border-block-end: var(--stroke-sm) solid var(--border-clr-subtle);
6
8
  }
7
9
  .pui-tabs__item {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pienter/ui",
3
- "version": "0.10.0",
3
+ "version": "0.13.0",
4
4
  "description": "Shared Pienter UI components, styles, icons, and browser utilities.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -64,6 +64,8 @@
64
64
  "./components/Collapsible.vue": "./components/layout/collapsible/Collapsible.vue",
65
65
  "./components/AppLayout.vue": "./components/layout/app-layout/AppLayout.vue",
66
66
  "./components/PageHeader.vue": "./components/layout/page-header/PageHeader.vue",
67
+ "./components/Panel.vue": "./components/layout/record-layout/Panel.vue",
68
+ "./components/RecordLayout.vue": "./components/layout/record-layout/RecordLayout.vue",
67
69
  "./components/Separator.vue": "./components/layout/separator/Separator.vue",
68
70
  "./components/Table.vue": "./components/layout/table/Table.vue",
69
71
  "./components/TableRow.vue": "./components/layout/table/TableRow.vue",
@@ -33,10 +33,15 @@
33
33
 
34
34
  a {
35
35
  color: var(--text-clr-brand);
36
- text-underline-offset: 0.14em;
36
+ text-underline-offset: var(--space-3xs);
37
37
  }
38
38
 
39
39
  a:hover {
40
40
  color: var(--text-clr-brand-hover);
41
41
  }
42
+
43
+ a:focus-visible {
44
+ outline: var(--outline-width) solid var(--outline-clr-base);
45
+ outline-offset: var(--outline-offset);
46
+ }
42
47
  }
@@ -27,19 +27,15 @@
27
27
  float: none;
28
28
  }
29
29
 
30
- /* `data-layout="inline"`: control first in DOM order, label beside it,
31
- * hint and errors below spanning both columns. */
30
+ /* `data-layout="inline"`: label and control share a row in DOM order
31
+ * (control first for Checkbox/Switch, label first elsewhere); hint and
32
+ * errors drop below spanning both columns. */
32
33
  .pui-field[data-layout='inline'] {
33
34
  grid-template-columns: max-content 1fr;
34
35
  column-gap: var(--space-xs);
35
36
  align-items: center;
36
37
  }
37
38
 
38
- .pui-field[data-layout='inline'] .pui-field__label {
39
- cursor: pointer;
40
- user-select: none;
41
- }
42
-
43
39
  .pui-field[data-layout='inline'] .pui-field__hint {
44
40
  grid-column: 1 / -1;
45
41
  }
@@ -100,6 +96,13 @@
100
96
  cursor: not-allowed;
101
97
  }
102
98
 
99
+ /* `data-readonly="true"`: same border, text and focus ring as the
100
+ * default state on a muted surface; the control stays in the tab order. */
101
+ .pui-field[data-readonly='true'] .pui-input,
102
+ .pui-field[data-readonly='true'] .pui-textarea {
103
+ background: var(--bg-clr-surface-2);
104
+ }
105
+
103
106
  .pui-field[data-status='error'] .pui-input,
104
107
  .pui-field[data-status='error'] .pui-textarea {
105
108
  border-color: var(--border-clr-danger);