@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,28 @@
1
+ import type { ValuePath } from '../../../utils/cms/index.js';
2
+
3
+ interface RecordFieldBase {
4
+ name: string;
5
+ label: string;
6
+ /** Defaults to the literal property name, without splitting dots. */
7
+ path?: ValuePath;
8
+ hint?: string;
9
+ required?: boolean;
10
+ disabled?: boolean;
11
+ }
12
+
13
+ export type RecordFormField = RecordFieldBase &
14
+ (
15
+ | {
16
+ type?: 'text' | 'email';
17
+ placeholder?: string;
18
+ autocomplete?: string;
19
+ }
20
+ | { type: 'textarea'; placeholder?: string; rows?: number }
21
+ | {
22
+ type: 'select';
23
+ options: { value: string; label: string }[];
24
+ placeholder?: string;
25
+ }
26
+ | { type: 'checkbox' }
27
+ | { type: 'custom' }
28
+ );
@@ -1,14 +1,21 @@
1
1
  <template>
2
2
  <div class="pui-field" :data-status="computedStatus">
3
- <label v-if="label" class="pui-field__label" :for="inputId">{{
4
- label
5
- }}</label>
3
+ <label v-if="label" class="pui-field__label" :for="inputId"
4
+ >{{ label
5
+ }}<span
6
+ v-if="required"
7
+ class="pui-field__required"
8
+ aria-hidden="true"
9
+ >*</span
10
+ ></label
11
+ >
6
12
  <div class="pui-select">
7
13
  <select
8
14
  v-bind="$attrs"
9
15
  :id="inputId"
10
16
  class="pui-select__control"
11
17
  :name="name"
18
+ :required="required"
12
19
  :disabled="disabled"
13
20
  :value="modelValue"
14
21
  :aria-invalid="hasErrors ? 'true' : undefined"
@@ -40,7 +47,7 @@
40
47
  v-if="errors.length"
41
48
  :id="errorsId"
42
49
  class="pui-field__hint"
43
- role="alert"
50
+ role="status"
44
51
  >
45
52
  <li v-for="error in errors" :key="error">{{ error }}</li>
46
53
  </ul>
@@ -60,6 +67,7 @@ const props = withDefaults(
60
67
  label?: string;
61
68
  modelValue?: string;
62
69
  name?: string;
70
+ required?: boolean;
63
71
  disabled?: boolean;
64
72
  options: { value: string; label: string }[];
65
73
  placeholder?: string;
@@ -72,6 +80,7 @@ const props = withDefaults(
72
80
  label: undefined,
73
81
  modelValue: undefined,
74
82
  name: undefined,
83
+ required: false,
75
84
  disabled: false,
76
85
  placeholder: undefined,
77
86
  hint: undefined,
@@ -2,10 +2,9 @@
2
2
  /* ===== pui-select (form-primitive control) =====
3
3
  *
4
4
  * Wrapped by `.pui-field` (the shared form-primitive scaffold owned by
5
- * text-input.css). The status-driven recolour (`error` / `success` border)
5
+ * `styles/4-components/form-field.css`). The status-driven recolour (`error` / `success` border)
6
6
  * is applied by the parent `.pui-field[data-status]` selector below, mirroring
7
- * `.pui-input` in text-input.css. Focus ring uses the same box-shadow pattern
8
- * as `.pui-input` for visual consistency.
7
+ * `.pui-input` in form-field.css.
9
8
  */
10
9
 
11
10
  .pui-select {
@@ -20,19 +19,17 @@
20
19
  padding: 0.65em 2.4em 0.65em 0.85em;
21
20
  font: inherit;
22
21
  background: var(--bg-clr-surface);
23
- border: var(--stroke-sm) solid var(--border-clr-base);
22
+ border: var(--stroke-sm) solid var(--border-clr-input);
24
23
  border-radius: var(--radius-sm);
25
24
  cursor: pointer;
26
25
  color: var(--text-clr-base);
27
- transition:
28
- border-color 0.12s ease,
29
- box-shadow 0.12s ease;
26
+ transition: border-color var(--duration-fast) var(--ease-base);
30
27
  }
31
28
 
32
29
  .pui-select__control:focus-visible {
33
- outline: none;
34
30
  border-color: var(--border-clr-brand);
35
- box-shadow: 0 0 0 3px hsl(from var(--outline-clr-base) h s l / 0.25);
31
+ outline: var(--outline-width) solid var(--outline-clr-base);
32
+ outline-offset: var(--outline-offset);
36
33
  }
37
34
 
38
35
  .pui-select__control:disabled {
@@ -56,7 +56,7 @@
56
56
  v-if="errors.length"
57
57
  :id="errorsId"
58
58
  class="pui-field__hint"
59
- role="alert"
59
+ role="status"
60
60
  >
61
61
  <li v-for="error in errors" :key="error">{{ error }}</li>
62
62
  </ul>
@@ -25,7 +25,7 @@
25
25
  v-if="errors.length"
26
26
  :id="errorsId"
27
27
  class="pui-field__hint"
28
- role="alert"
28
+ role="status"
29
29
  >
30
30
  <li v-for="error in errors" :key="error">{{ error }}</li>
31
31
  </ul>
@@ -1,6 +1,14 @@
1
1
  <template>
2
2
  <div class="pui-field" :data-status="computedStatus">
3
- <label class="pui-field__label" :for="inputId">{{ label }}</label>
3
+ <label class="pui-field__label" :for="inputId"
4
+ >{{ label
5
+ }}<span
6
+ v-if="required"
7
+ class="pui-field__required"
8
+ aria-hidden="true"
9
+ >*</span
10
+ ></label
11
+ >
4
12
  <!--
5
13
  onWrapperClick only forwards a click on the empty chrome of the field
6
14
  to the real <input> below (a larger pointer target). That input is a
@@ -42,6 +50,7 @@
42
50
  :name="name"
43
51
  :placeholder="placeholder"
44
52
  :disabled="disabled || maxReached"
53
+ :aria-required="required ? 'true' : undefined"
45
54
  :aria-invalid="hasErrors ? 'true' : undefined"
46
55
  :aria-describedby="describedBy"
47
56
  @keydown="onKeydown"
@@ -53,7 +62,7 @@
53
62
  v-if="errors.length"
54
63
  :id="errorsId"
55
64
  class="pui-field__hint"
56
- role="alert"
65
+ role="status"
57
66
  >
58
67
  <li v-for="error in errors" :key="error">{{ error }}</li>
59
68
  </ul>
@@ -63,6 +72,7 @@
63
72
  <script setup lang="ts">
64
73
  import { computed, ref } from 'vue';
65
74
  import { generateId } from '../../../utils/a11y/id.js';
75
+ import { announce } from '../../../utils/a11y/live-region.js';
66
76
 
67
77
  defineOptions({ inheritAttrs: false });
68
78
 
@@ -74,6 +84,8 @@ const props = withDefaults(
74
84
  modelValue: string[];
75
85
  name?: string;
76
86
  placeholder?: string;
87
+ /** Marks the field; set as `aria-required` on the entry input since the value is the tag list, not the typed text. */
88
+ required?: boolean;
77
89
  disabled?: boolean;
78
90
  hint?: string;
79
91
  errors?: string[];
@@ -93,6 +105,7 @@ const props = withDefaults(
93
105
  id: undefined,
94
106
  name: undefined,
95
107
  placeholder: undefined,
108
+ required: false,
96
109
  disabled: false,
97
110
  hint: undefined,
98
111
  errors: () => [],
@@ -153,8 +166,10 @@ function addTag(raw: string): boolean {
153
166
  function removeTag(index: number): void {
154
167
  if (props.disabled) return;
155
168
  if (index < 0 || index >= props.modelValue.length) return;
169
+ const tag = props.modelValue[index];
156
170
  const next = props.modelValue.filter((_, i) => i !== index);
157
171
  emit('update:modelValue', next);
172
+ announce(`Removed ${tag}`);
158
173
  // Return focus to the entry input so the keyboard user keeps typing.
159
174
  entryRef.value?.focus();
160
175
  }
@@ -2,7 +2,7 @@
2
2
  /* ===== pui-tags-input (flex-wrapping pills + entry input) =====
3
3
  *
4
4
  * TagsInput extends the locked Form-primitive scaffold (the outer
5
- * `.pui-field` wrapper from `text-input.css`) with a flex-wrapping
5
+ * `.pui-field` wrapper from `styles/4-components/form-field.css`) with a flex-wrapping
6
6
  * row that hosts existing tag pills followed by a text input where
7
7
  * the user types the next tag. The whole row reads as one
8
8
  * integrated control: clicking anywhere lands focus on the entry
@@ -23,19 +23,22 @@
23
23
  width: 100%;
24
24
  padding: 0.4em 0.55em;
25
25
  background: var(--bg-clr-surface);
26
- border: var(--stroke-sm) solid var(--border-clr-base);
26
+ border: var(--stroke-sm) solid var(--border-clr-input);
27
27
  border-radius: var(--radius-sm);
28
28
  color: var(--text-clr-base);
29
- transition:
30
- border-color 0.12s ease,
31
- box-shadow 0.12s ease;
29
+ transition: border-color var(--duration-fast) var(--ease-base);
32
30
  cursor: text;
33
31
  }
34
32
 
35
33
  .pui-tags-input:focus-within {
36
- outline: none;
37
34
  border-color: var(--border-clr-brand);
38
- box-shadow: 0 0 0 3px hsl(from var(--outline-clr-base) h s l / 0.25);
35
+ }
36
+
37
+ /* The entry is borderless, so the wrapper wears its ring; a focused
38
+ remove button keeps its own. */
39
+ .pui-tags-input:has(.pui-tags-input__entry:focus-visible) {
40
+ outline: var(--outline-width) solid var(--outline-clr-base);
41
+ outline-offset: var(--outline-offset);
39
42
  }
40
43
 
41
44
  .pui-tags-input[data-disabled='true'] {
@@ -59,7 +62,8 @@
59
62
  display: inline-flex;
60
63
  align-items: center;
61
64
  gap: var(--space-3xs);
62
- padding: 0.15em 0.5em;
65
+ padding-block: 0;
66
+ padding-inline: var(--space-2xs) var(--space-3xs);
63
67
  background: var(--bg-clr-surface-2);
64
68
  border: var(--stroke-sm) solid var(--border-clr-base);
65
69
  border-radius: var(--radius-pill, 999px);
@@ -84,8 +88,8 @@
84
88
  cursor: pointer;
85
89
  font: inherit;
86
90
  line-height: 1;
87
- width: 1.25em;
88
- height: 1.25em;
91
+ min-inline-size: var(--space-m);
92
+ min-block-size: var(--space-m);
89
93
  display: grid;
90
94
  place-items: center;
91
95
  border-radius: var(--radius-pill, 999px);
@@ -96,8 +100,8 @@
96
100
  }
97
101
 
98
102
  .pui-tags-input__tag-remove:focus-visible {
99
- outline: 2px solid var(--outline-clr-base);
100
- outline-offset: 1px;
103
+ outline: var(--outline-width) solid var(--outline-clr-base);
104
+ outline-offset: var(--outline-offset);
101
105
  }
102
106
 
103
107
  .pui-tags-input__tag-remove:disabled {
@@ -1,6 +1,14 @@
1
1
  <template>
2
2
  <div class="pui-field" :data-status="computedStatus">
3
- <label class="pui-field__label" :for="inputId">{{ label }}</label>
3
+ <label class="pui-field__label" :for="inputId"
4
+ >{{ label
5
+ }}<span
6
+ v-if="required"
7
+ class="pui-field__required"
8
+ aria-hidden="true"
9
+ >*</span
10
+ ></label
11
+ >
4
12
  <input
5
13
  v-bind="$attrs"
6
14
  :id="inputId"
@@ -27,7 +35,7 @@
27
35
  v-if="errors.length"
28
36
  :id="errorsId"
29
37
  class="pui-field__hint"
30
- role="alert"
38
+ role="status"
31
39
  >
32
40
  <li v-for="error in errors" :key="error">{{ error }}</li>
33
41
  </ul>
@@ -1,141 +1,22 @@
1
1
  @layer components {
2
- /* ===== pui-field (wrapper with label + hint) =====
3
- *
4
- * Default layout (`data-layout="stacked"` or unset): label above the
5
- * control, hint and errors below — one column, vertical flow. Used by
6
- * TextInput, Textarea, Select, NumberField, DateInput.
7
- *
8
- * Inline layout (`data-layout="inline"`): control left, label right
9
- * (next to the control), hint and errors below the row spanning both
10
- * columns. Used by compact form primitives (Switch, Checkbox,
11
- * RadioGroup individual items) where the control is small enough to
12
- * sit beside its label without the stacked label-above pattern. The
13
- * control is the FIRST DOM child so a label-following-input source
14
- * order can be styled via a grid template.
15
- */
16
-
17
- .pui-field {
18
- display: grid;
19
- gap: var(--space-2xs);
20
- }
21
-
22
- /* Zero <fieldset>'s user-agent defaults so it can host the pui-field grid
23
- * scaffold cleanly. Without this, browsers paint a 2px groove border, add
24
- * default block padding, and apply `min-inline-size: min-content` (which
25
- * forces the fieldset to never shrink below its widest child). RadioGroup
26
- * is the first form-primitive built on <fieldset>; future grouped-control
27
- * primitives (checkbox groups, date-range pickers) reuse this carve-out. */
28
- .pui-field:where(fieldset) {
29
- border: 0;
30
- padding: 0;
31
- margin: 0;
32
- min-inline-size: 0;
33
- }
34
-
35
- /* <legend> default rendering ("float" anchor, automatic padding-inline)
36
- * misaligns inside a grid <fieldset>; zero those so the legend behaves
37
- * like a normal block grid item, allowing `.pui-field`'s row gap to govern
38
- * the label-to-control spacing. */
39
- .pui-field__label:where(legend) {
40
- padding: 0;
41
- float: none;
42
- }
43
-
44
- .pui-field[data-layout='inline'] {
45
- grid-template-columns: max-content 1fr;
46
- column-gap: var(--space-xs);
47
- align-items: center;
48
- }
49
-
50
- .pui-field[data-layout='inline'] .pui-field__label {
51
- /* Pointer affordance for clickable label paired with a small control. */
52
- cursor: pointer;
53
- user-select: none;
54
- }
55
-
56
- .pui-field[data-layout='inline'] .pui-field__hint {
57
- /* Hint and errors span both columns so they sit below the row. */
58
- grid-column: 1 / -1;
59
- }
60
-
61
- .pui-field__label {
62
- font-size: var(--step--1);
63
- font-weight: var(--fw-bold);
64
- }
65
-
66
- .pui-field__hint {
67
- font-size: var(--step--2);
68
- color: var(--text-clr-muted);
69
- margin: 0;
70
- }
71
-
72
- .pui-field[data-status='error'] .pui-field__hint {
73
- color: var(--text-clr-danger);
74
- }
75
-
76
- .pui-field[data-status='success'] .pui-field__hint {
77
- color: var(--text-clr-success);
78
- }
79
-
80
- /* ===== pui-input / pui-textarea (actual controls) ===== */
81
-
82
- .pui-input,
83
- .pui-textarea {
84
- width: 100%;
85
- padding: 0.65em 0.85em;
86
- font: inherit;
87
- background: var(--bg-clr-surface);
88
- border: var(--stroke-sm) solid var(--border-clr-base);
89
- border-radius: var(--radius-sm);
90
- color: var(--text-clr-base);
91
- transition:
92
- border-color 0.12s ease,
93
- box-shadow 0.12s ease;
94
- }
95
-
96
- .pui-textarea {
97
- min-height: 6rem;
98
- resize: vertical;
99
- }
100
-
101
- .pui-input:focus-visible,
102
- .pui-textarea:focus-visible {
103
- outline: none;
104
- border-color: var(--border-clr-brand);
105
- box-shadow: 0 0 0 3px hsl(from var(--outline-clr-base) h s l / 0.25);
106
- }
107
-
108
- .pui-input:disabled,
109
- .pui-textarea:disabled {
110
- background: var(--bg-clr-surface-2);
111
- color: var(--text-clr-muted);
112
- cursor: not-allowed;
113
- }
114
-
115
- /* Status states on parent .pui-field */
116
-
117
- .pui-field[data-status='error'] .pui-input,
118
- .pui-field[data-status='error'] .pui-textarea {
119
- border-color: var(--border-clr-danger);
120
- }
121
-
122
- .pui-field[data-status='success'] .pui-input,
123
- .pui-field[data-status='success'] .pui-textarea {
124
- border-color: var(--border-clr-success);
125
- }
126
-
127
- /* ===== pui-inputgroup (prefix / suffix addons) ===== */
2
+ /* The `.pui-field` scaffold and the `.pui-input` surface live in
3
+ * `styles/4-components/form-field.css`; only the input group is TextInput's. */
128
4
 
129
5
  .pui-inputgroup {
130
6
  display: flex;
131
- border: var(--stroke-sm) solid var(--border-clr-base);
7
+ border: var(--stroke-sm) solid var(--border-clr-input);
132
8
  border-radius: var(--radius-sm);
133
9
  overflow: hidden;
134
10
  }
135
11
 
136
12
  .pui-inputgroup:focus-within {
137
13
  border-color: var(--border-clr-brand);
138
- box-shadow: 0 0 0 3px hsl(from var(--outline-clr-base) h s l / 0.25);
14
+ }
15
+
16
+ /* `overflow: hidden` would clip the input's own ring, so the group wears it. */
17
+ .pui-inputgroup:has(.pui-input:focus-visible) {
18
+ outline: var(--outline-width) solid var(--outline-clr-base);
19
+ outline-offset: var(--outline-offset);
139
20
  }
140
21
 
141
22
  .pui-inputgroup .pui-input {
@@ -144,7 +25,7 @@
144
25
  }
145
26
 
146
27
  .pui-inputgroup .pui-input:focus-visible {
147
- box-shadow: none;
28
+ outline: none;
148
29
  }
149
30
 
150
31
  .pui-inputgroup__addon {
@@ -1,6 +1,14 @@
1
1
  <template>
2
2
  <div class="pui-field" :data-status="computedStatus">
3
- <label class="pui-field__label" :for="inputId">{{ label }}</label>
3
+ <label class="pui-field__label" :for="inputId"
4
+ >{{ label
5
+ }}<span
6
+ v-if="required"
7
+ class="pui-field__required"
8
+ aria-hidden="true"
9
+ >*</span
10
+ ></label
11
+ >
4
12
  <textarea
5
13
  v-bind="$attrs"
6
14
  :id="inputId"
@@ -28,7 +36,7 @@
28
36
  v-if="errors.length"
29
37
  :id="errorsId"
30
38
  class="pui-field__hint"
31
- role="alert"
39
+ role="status"
32
40
  >
33
41
  <li v-for="error in errors" :key="error">{{ error }}</li>
34
42
  </ul>
@@ -1,25 +1,9 @@
1
1
  @layer components {
2
- /* The shared `.pui-textarea` rules (sizing, padding, border, focus,
3
- * disabled, status-driven recolour) live alongside `.pui-input` in
4
- * `components/form/text-input/text-input.css` because both controls share
5
- * the form-primitive scaffold (per CONVENTIONS § Component archetypes
6
- * → Form-primitive wrapper). When a fifth+ primitive ships, those
7
- * shared rules will move into a dedicated `form-field.css` per the
8
- * deferred-extraction note in CONVENTIONS.
9
- *
10
- * This file only carries Textarea-specific rules that don't fit the
11
- * shared scaffold — currently just the `:autoResize` opt-in styling.
12
- */
2
+ /* `.pui-textarea` itself is styled by `styles/4-components/form-field.css`. */
13
3
 
14
- /* Auto-resize variant — opt-in via the `autoResize` prop, which sets
15
- * `style="field-sizing: content"` inline on the <textarea>. The
16
- * inline style is the only documented path, so the
17
- * selector matches it directly. Browsers without `field-sizing`
18
- * support fall back gracefully to the default fixed-rows/min-height
19
- * box. */
4
+ /* `autoResize` sets `style="field-sizing: content"` inline; the box grows
5
+ * with content, so the manual resize handle is redundant. */
20
6
  .pui-textarea[style*='field-sizing'] {
21
- /* Vertical resize handle is redundant when content sizing is on;
22
- * the box grows with content on its own. */
23
7
  resize: none;
24
8
  }
25
9
  }
@@ -0,0 +1,116 @@
1
+ <script setup lang="ts">
2
+ import { computed, useId, watch } from 'vue';
3
+ import IconButton from '../../action/button/IconButton.vue';
4
+ import Sidebar from '../../navigation/sidebar/Sidebar.vue';
5
+ import type { SidebarItem } from '../../navigation/sidebar/types.js';
6
+
7
+ const props = withDefaults(
8
+ defineProps<{
9
+ topItems: SidebarItem[];
10
+ bottomItems?: SidebarItem[];
11
+ activeHref?: string | null;
12
+ collapsed?: boolean;
13
+ collapsedSubmenu?: 'hidden' | 'panel';
14
+ navigationLabel?: string;
15
+ drawerLabel?: string;
16
+ skipLabel?: string;
17
+ mainId?: string;
18
+ navigationId?: string;
19
+ }>(),
20
+ {
21
+ bottomItems: () => [],
22
+ activeHref: null,
23
+ collapsed: false,
24
+ collapsedSubmenu: 'hidden',
25
+ navigationLabel: 'Main navigation',
26
+ drawerLabel: 'Open navigation',
27
+ skipLabel: 'Skip to content',
28
+ mainId: undefined,
29
+ navigationId: undefined,
30
+ },
31
+ );
32
+
33
+ const emit = defineEmits<{ navigate: [event: MouseEvent] }>();
34
+ const drawerOpen = defineModel<boolean>('drawerOpen', { default: false });
35
+ const expandedGroups = defineModel<string[]>('expandedGroups');
36
+
37
+ const instanceId = useId();
38
+ const resolvedMainId = computed(() => props.mainId ?? `${instanceId}-main`);
39
+ const resolvedNavigationId = computed(
40
+ () => props.navigationId ?? `${instanceId}-navigation`,
41
+ );
42
+
43
+ function navigate(event: MouseEvent): void {
44
+ emit('navigate', event);
45
+ const target = event.target as Element | null;
46
+ if (target?.closest('a[data-sidebar-link]')) drawerOpen.value = false;
47
+ }
48
+
49
+ watch(
50
+ () => props.activeHref,
51
+ (next, previous) => {
52
+ if (next !== previous) drawerOpen.value = false;
53
+ },
54
+ );
55
+ </script>
56
+
57
+ <template>
58
+ <div class="pui-app-layout">
59
+ <a class="pui-app-layout__skip-link" :href="`#${resolvedMainId}`">
60
+ {{ skipLabel }}
61
+ </a>
62
+ <header class="pui-app-layout__header">
63
+ <IconButton
64
+ class="pui-app-layout__drawer-toggle"
65
+ :label="drawerLabel"
66
+ name="menu"
67
+ variant="ghost"
68
+ size="sm"
69
+ :aria-expanded="drawerOpen ? 'true' : undefined"
70
+ :aria-controls="resolvedNavigationId"
71
+ @click="drawerOpen = true"
72
+ />
73
+ <div v-if="$slots.brand" class="pui-app-layout__brand">
74
+ <slot name="brand" />
75
+ </div>
76
+ <div v-if="$slots.tools" class="pui-app-layout__tools">
77
+ <slot name="tools" />
78
+ </div>
79
+ </header>
80
+ <div class="pui-app-layout__body">
81
+ <Sidebar
82
+ :id="resolvedNavigationId"
83
+ class="pui-app-layout__navigation"
84
+ :top-items="topItems"
85
+ :bottom-items="bottomItems"
86
+ :active-href="activeHref"
87
+ :collapsed="collapsed"
88
+ :collapsed-submenu="collapsedSubmenu"
89
+ :drawer-open="drawerOpen"
90
+ :expanded-groups="expandedGroups"
91
+ :aria-label="navigationLabel"
92
+ @click="navigate"
93
+ @update:drawer-open="drawerOpen = $event"
94
+ @update:expanded-groups="expandedGroups = $event"
95
+ >
96
+ <template v-if="$slots['navigation-brand']" #brand>
97
+ <slot name="navigation-brand" />
98
+ </template>
99
+ <template v-if="$slots['navigation-bottom']" #bottom>
100
+ <slot name="navigation-bottom" />
101
+ </template>
102
+ </Sidebar>
103
+ <main
104
+ :id="resolvedMainId"
105
+ class="pui-app-layout__main"
106
+ tabindex="-1"
107
+ >
108
+ <slot />
109
+ </main>
110
+ </div>
111
+ </div>
112
+ </template>
113
+
114
+ <style>
115
+ @import './app-layout.css';
116
+ </style>