@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
@@ -21,6 +26,7 @@
21
26
  :step="step"
22
27
  :required="required"
23
28
  :disabled="disabled"
29
+ :readonly="readonly"
24
30
  :aria-invalid="hasErrors ? 'true' : undefined"
25
31
  :aria-describedby="describedBy"
26
32
  @input="
@@ -80,9 +86,13 @@ const props = withDefaults(
80
86
  step?: number | string;
81
87
  required?: boolean;
82
88
  disabled?: boolean;
89
+ /** Read-only: the value shows in the field chrome, stays focusable and cannot be edited. */
90
+ readonly?: boolean;
83
91
  hint?: string;
84
92
  errors?: string[];
85
93
  status?: 'error' | 'success';
94
+ /** `inline` puts the label beside the control; hint and errors wrap below. */
95
+ layout?: 'stacked' | 'inline';
86
96
  }>(),
87
97
  {
88
98
  id: undefined,
@@ -93,9 +103,11 @@ const props = withDefaults(
93
103
  step: undefined,
94
104
  required: false,
95
105
  disabled: false,
106
+ readonly: false,
96
107
  hint: undefined,
97
108
  errors: () => [],
98
109
  status: undefined,
110
+ layout: 'stacked',
99
111
  },
100
112
  );
101
113
 
@@ -22,7 +22,7 @@
22
22
  {{ statusMessage }}
23
23
  </div>
24
24
 
25
- <div class="pui-form__actions">
25
+ <div v-if="!readonly" class="pui-form__actions">
26
26
  <slot name="actions">
27
27
  <button
28
28
  class="pui-btn"
@@ -52,6 +52,8 @@ const props = withDefaults(
52
52
  defineProps<{
53
53
  submitLabel?: string;
54
54
  busy?: boolean;
55
+ /** Read-only presentation: renders no actions row and ignores every submit, Enter included. */
56
+ readonly?: boolean;
55
57
  /** Top-level errors not tied to a specific field. */
56
58
  errors?: string[];
57
59
  /** Server-side per-field errors. Consumers SHALL replace the object reference rather than mutate in place. */
@@ -64,6 +66,7 @@ const props = withDefaults(
64
66
  {
65
67
  submitLabel: 'Submit',
66
68
  busy: false,
69
+ readonly: false,
67
70
  errors: () => [],
68
71
  fieldErrors: () => ({}),
69
72
  statusMessage: undefined,
@@ -105,7 +108,7 @@ const allFieldErrors = computed<Record<string, string[]>>(() => {
105
108
 
106
109
  function handleSubmit(): void {
107
110
  const formEl = formRef.value;
108
- if (!formEl) return;
111
+ if (!formEl || props.readonly) return;
109
112
 
110
113
  const formData = new FormData(formEl);
111
114
 
@@ -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
@@ -20,16 +25,17 @@
20
25
  :min="min"
21
26
  :max="max"
22
27
  :step="step"
23
- :placeholder="placeholder"
28
+ :placeholder="readonly ? undefined : placeholder"
24
29
  :required="required"
25
30
  :disabled="disabled"
31
+ :readonly="readonly"
26
32
  :value="modelValue"
27
33
  :aria-invalid="hasErrors ? 'true' : undefined"
28
34
  :aria-describedby="describedBy"
29
35
  @input="onInput"
30
36
  @blur="emit('blur')"
31
37
  />
32
- <div class="pui-number-field__steppers">
38
+ <div v-if="!readonly" class="pui-number-field__steppers">
33
39
  <button
34
40
  type="button"
35
41
  class="pui-number-field__step"
@@ -87,9 +93,13 @@ const props = withDefaults(
87
93
  placeholder?: string;
88
94
  required?: boolean;
89
95
  disabled?: boolean;
96
+ /** Read-only: the value shows in the field chrome without steppers, stays focusable and cannot be edited. */
97
+ readonly?: boolean;
90
98
  hint?: string;
91
99
  errors?: string[];
92
100
  status?: 'error' | 'success';
101
+ /** `inline` puts the label beside the control; hint and errors wrap below. */
102
+ layout?: 'stacked' | 'inline';
93
103
  }>(),
94
104
  {
95
105
  id: undefined,
@@ -101,9 +111,11 @@ const props = withDefaults(
101
111
  placeholder: undefined,
102
112
  required: false,
103
113
  disabled: false,
114
+ readonly: false,
104
115
  hint: undefined,
105
116
  errors: () => [],
106
117
  status: undefined,
118
+ layout: 'stacked',
107
119
  },
108
120
  );
109
121
 
@@ -142,7 +154,7 @@ function onInput(event: Event): void {
142
154
  }
143
155
 
144
156
  function handleStep(direction: 'decrement' | 'increment'): void {
145
- if (props.disabled) return;
157
+ if (props.disabled || props.readonly) return;
146
158
  const input = inputRef.value;
147
159
  if (!input) return;
148
160
  if (direction === 'increment') {
@@ -42,6 +42,12 @@
42
42
  margin: 0;
43
43
  }
44
44
 
45
+ /* Read-only renders no stepper column, so the input keeps its own corners. */
46
+ .pui-field[data-readonly='true'] .pui-number-field__input {
47
+ border-top-right-radius: var(--radius-sm);
48
+ border-bottom-right-radius: var(--radius-sm);
49
+ }
50
+
45
51
  /* Stacked column for the +/- buttons. Sits flush against the
46
52
  * input's right edge; each button takes half the column height. */
47
53
  .pui-number-field__steppers {
@@ -3,8 +3,10 @@
3
3
  v-bind="$attrs"
4
4
  :id="fieldId"
5
5
  class="pui-field"
6
+ :data-readonly="readonly ? 'true' : undefined"
6
7
  :data-status="computedStatus"
7
8
  role="radiogroup"
9
+ :aria-readonly="readonly ? 'true' : undefined"
8
10
  :aria-describedby="describedBy"
9
11
  :aria-invalid="hasErrors ? 'true' : undefined"
10
12
  >
@@ -31,7 +33,9 @@
31
33
  :aria-checked="
32
34
  modelValue === option.value ? 'true' : 'false'
33
35
  "
34
- @change="emit('update:modelValue', option.value)"
36
+ @click="onClick"
37
+ @keydown="onKeydown"
38
+ @change="onChange(option.value)"
35
39
  />
36
40
  <span class="pui-radio-group__label">{{ option.label }}</span>
37
41
  </label>
@@ -62,6 +66,8 @@ const props = withDefaults(
62
66
  options: RadioOption[];
63
67
  modelValue?: string;
64
68
  disabled?: boolean;
69
+ /** Read-only: `aria-readonly` on the group, the checked radio stays focusable, selection is ignored. */
70
+ readonly?: boolean;
65
71
  orientation?: 'vertical' | 'horizontal';
66
72
  hint?: string;
67
73
  errors?: string[];
@@ -71,6 +77,7 @@ const props = withDefaults(
71
77
  id: undefined,
72
78
  modelValue: undefined,
73
79
  disabled: false,
80
+ readonly: false,
74
81
  orientation: 'vertical',
75
82
  hint: undefined,
76
83
  errors: () => [],
@@ -100,6 +107,21 @@ const describedBy = computed(() => {
100
107
  if (hasErrors.value) ids.push(errorsId);
101
108
  return ids.length > 0 ? ids.join(' ') : undefined;
102
109
  });
110
+
111
+ // Cancelling `click` keeps the native checked state and fires no `change`;
112
+ // arrow keys move the selection without a click, so they are cancelled too.
113
+ function onClick(event: MouseEvent): void {
114
+ if (props.readonly) event.preventDefault();
115
+ }
116
+
117
+ function onKeydown(event: KeyboardEvent): void {
118
+ if (props.readonly && event.key.startsWith('Arrow')) event.preventDefault();
119
+ }
120
+
121
+ function onChange(value: string): void {
122
+ if (props.readonly) return;
123
+ emit('update:modelValue', value);
124
+ }
103
125
  </script>
104
126
 
105
127
  <style>
@@ -102,6 +102,15 @@
102
102
  cursor: not-allowed;
103
103
  }
104
104
 
105
+ .pui-field[data-readonly='true'] .pui-radio-group__item,
106
+ .pui-field[data-readonly='true'] .pui-radio {
107
+ cursor: default;
108
+ }
109
+
110
+ .pui-field[data-readonly='true'] .pui-radio:not(:checked) {
111
+ background: var(--bg-clr-surface-2);
112
+ }
113
+
105
114
  /* Status-driven recolour from the parent pui-field wrapper. */
106
115
  .pui-field[data-status='error'] .pui-radio {
107
116
  border-color: var(--border-clr-danger);
@@ -23,8 +23,10 @@ const props = withDefaults(
23
23
  fields: readonly RecordFormField[];
24
24
  issues?: readonly ValidationIssue[];
25
25
  busy?: boolean;
26
+ /** View mode: every field renders read-only and `update` is a no-op. */
27
+ readonly?: boolean;
26
28
  }>(),
27
- { issues: () => [], busy: false },
29
+ { issues: () => [], busy: false, readonly: false },
28
30
  );
29
31
  const emit = defineEmits<{
30
32
  'update:modelValue': [value: T];
@@ -44,8 +46,14 @@ function textFor(field: RecordFormField): string {
44
46
  const value = valueFor(field);
45
47
  return typeof value === 'string' ? value : '';
46
48
  }
49
+ function altFor(field: RecordFormField): string {
50
+ const name = field.type === 'image' ? field.altName : undefined;
51
+ if (!name) return '';
52
+ const sibling = props.fields.find((other) => other.name === name);
53
+ return textFor(sibling ?? { name, label: '' });
54
+ }
47
55
  function update(field: RecordFormField, value: unknown): void {
48
- if (props.busy || field.disabled) return;
56
+ if (props.busy || props.readonly || field.disabled) return;
49
57
  const path = pathFor(field);
50
58
  emit('update:modelValue', updatePath(props.modelValue, path, value));
51
59
  emit('field-change', path);
@@ -66,6 +74,7 @@ function update(field: RecordFormField, value: unknown): void {
66
74
  :update="(value: unknown) => update(field, value)"
67
75
  :errors="errors"
68
76
  :disabled="busy || !!field.disabled"
77
+ :readonly="readonly"
69
78
  >
70
79
  <PuiTextarea
71
80
  v-if="field.type === 'textarea'"
@@ -75,6 +84,7 @@ function update(field: RecordFormField, value: unknown): void {
75
84
  :hint="field.hint"
76
85
  :required="field.required"
77
86
  :disabled="busy || field.disabled"
87
+ :readonly="readonly"
78
88
  :placeholder="field.placeholder"
79
89
  :rows="field.rows"
80
90
  :errors="errors"
@@ -88,6 +98,7 @@ function update(field: RecordFormField, value: unknown): void {
88
98
  :hint="field.hint"
89
99
  :required="field.required"
90
100
  :disabled="busy || field.disabled"
101
+ :readonly="readonly"
91
102
  :placeholder="field.placeholder"
92
103
  :options="field.options"
93
104
  :errors="errors"
@@ -101,9 +112,35 @@ function update(field: RecordFormField, value: unknown): void {
101
112
  :hint="field.hint"
102
113
  :required="field.required"
103
114
  :disabled="busy || field.disabled"
115
+ :readonly="readonly"
104
116
  :errors="errors"
105
117
  @update:model-value="update(field, $event)"
106
118
  />
119
+ <template v-else-if="field.type === 'image'">
120
+ <div v-if="readonly" class="pui-field" data-readonly="true">
121
+ <span class="pui-field__label">{{ field.label }}</span>
122
+ <img
123
+ v-if="textFor(field)"
124
+ class="pui-record-fields__image"
125
+ :src="textFor(field)"
126
+ :alt="altFor(field)"
127
+ />
128
+ <p v-else class="pui-record-fields__empty">—</p>
129
+ </div>
130
+ <TextInput
131
+ v-else
132
+ :label="field.label"
133
+ :name="JSON.stringify(pathFor(field))"
134
+ :model-value="textFor(field)"
135
+ type="url"
136
+ :hint="field.hint"
137
+ :required="field.required"
138
+ :disabled="busy || field.disabled"
139
+ :placeholder="field.placeholder"
140
+ :errors="errors"
141
+ @update:model-value="update(field, $event)"
142
+ />
143
+ </template>
107
144
  <TextInput
108
145
  v-else-if="field.type !== 'custom'"
109
146
  :label="field.label"
@@ -113,6 +150,7 @@ function update(field: RecordFormField, value: unknown): void {
113
150
  :hint="field.hint"
114
151
  :required="field.required"
115
152
  :disabled="busy || field.disabled"
153
+ :readonly="readonly"
116
154
  :placeholder="field.placeholder"
117
155
  :autocomplete="field.autocomplete"
118
156
  :errors="errors"
@@ -23,6 +23,8 @@ const props = withDefaults(
23
23
  issues?: readonly ValidationIssue[];
24
24
  errors?: string[];
25
25
  busy?: boolean;
26
+ /** View mode: fields render read-only, the actions row is gone and `submit` never fires. */
27
+ readonly?: boolean;
26
28
  submitLabel?: string;
27
29
  statusMessage?: string;
28
30
  }>(),
@@ -30,6 +32,7 @@ const props = withDefaults(
30
32
  issues: () => [],
31
33
  errors: () => [],
32
34
  busy: false,
35
+ readonly: false,
33
36
  submitLabel: 'Save changes',
34
37
  statusMessage: undefined,
35
38
  },
@@ -64,7 +67,8 @@ const summary = computed(() => [
64
67
  ]);
65
68
 
66
69
  function submit(): void {
67
- if (!props.busy) emit('submit', cloneRecord(props.modelValue));
70
+ if (props.busy || props.readonly) return;
71
+ emit('submit', cloneRecord(props.modelValue));
68
72
  }
69
73
  </script>
70
74
 
@@ -72,6 +76,7 @@ function submit(): void {
72
76
  <Form
73
77
  class="pui-record-form"
74
78
  :busy="busy"
79
+ :readonly="readonly"
75
80
  :errors="summary"
76
81
  :field-errors="fieldErrors"
77
82
  :status-message="statusMessage"
@@ -84,6 +89,7 @@ function submit(): void {
84
89
  :fields="fields"
85
90
  :issues="issues"
86
91
  :busy="busy"
92
+ :readonly="readonly"
87
93
  @update:model-value="emit('update:modelValue', $event)"
88
94
  @field-change="emit('field-change', $event)"
89
95
  >
@@ -12,4 +12,18 @@
12
12
  gap: var(--space-s);
13
13
  min-inline-size: 0;
14
14
  }
15
+
16
+ .pui-record-fields__image {
17
+ justify-self: start;
18
+ max-inline-size: 100%;
19
+ max-block-size: var(--space-3xl);
20
+ border: var(--stroke-sm) solid var(--border-clr-subtle);
21
+ border-radius: var(--radius-sm);
22
+ background: var(--bg-clr-surface-2);
23
+ }
24
+
25
+ .pui-record-fields__empty {
26
+ margin: 0;
27
+ color: var(--text-clr-muted);
28
+ }
15
29
  }
@@ -23,6 +23,12 @@ export type RecordFormField = RecordFieldBase &
23
23
  options: { value: string; label: string }[];
24
24
  placeholder?: string;
25
25
  }
26
+ | {
27
+ type: 'image';
28
+ placeholder?: string;
29
+ /** `name` of the field in this list holding the alternative text. */
30
+ altName?: string;
31
+ }
26
32
  | { type: 'checkbox' }
27
33
  | { type: 'custom' }
28
34
  );
@@ -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 v-if="label" class="pui-field__label" :for="inputId"
4
9
  >{{ label
5
10
  }}<span
@@ -18,14 +23,12 @@
18
23
  :required="required"
19
24
  :disabled="disabled"
20
25
  :value="modelValue"
26
+ :aria-readonly="readonly ? 'true' : undefined"
21
27
  :aria-invalid="hasErrors ? 'true' : undefined"
22
28
  :aria-describedby="describedBy"
23
- @change="
24
- emit(
25
- 'update:modelValue',
26
- ($event.target as HTMLSelectElement).value,
27
- )
28
- "
29
+ @change="onChange"
30
+ @mousedown="onMousedown"
31
+ @keydown="onKeydown"
29
32
  >
30
33
  <option v-if="placeholder" value="" disabled hidden>
31
34
  {{ placeholder }}
@@ -69,11 +72,15 @@ const props = withDefaults(
69
72
  name?: string;
70
73
  required?: boolean;
71
74
  disabled?: boolean;
75
+ /** Read-only: `aria-readonly`, still focusable, but the list does not open and changes are ignored. */
76
+ readonly?: boolean;
72
77
  options: { value: string; label: string }[];
73
78
  placeholder?: string;
74
79
  hint?: string;
75
80
  errors?: string[];
76
81
  status?: 'error' | 'success';
82
+ /** `inline` puts the label beside the control; hint and errors wrap below. */
83
+ layout?: 'stacked' | 'inline';
77
84
  }>(),
78
85
  {
79
86
  id: undefined,
@@ -82,10 +89,12 @@ const props = withDefaults(
82
89
  name: undefined,
83
90
  required: false,
84
91
  disabled: false,
92
+ readonly: false,
85
93
  placeholder: undefined,
86
94
  hint: undefined,
87
95
  errors: () => [],
88
96
  status: undefined,
97
+ layout: 'stacked',
89
98
  },
90
99
  );
91
100
 
@@ -111,6 +120,30 @@ const describedBy = computed(() => {
111
120
  if (hasErrors.value) ids.push(errorsId);
112
121
  return ids.length > 0 ? ids.join(' ') : undefined;
113
122
  });
123
+
124
+ function onChange(event: Event): void {
125
+ const target = event.target as HTMLSelectElement;
126
+ if (props.readonly) {
127
+ target.value = props.modelValue ?? '';
128
+ return;
129
+ }
130
+ emit('update:modelValue', target.value);
131
+ }
132
+
133
+ // A `<select>` has no `readonly`: keep it focusable but stop the picker
134
+ // from opening and the keys that change the value without opening it.
135
+ function onMousedown(event: MouseEvent): void {
136
+ if (!props.readonly) return;
137
+ event.preventDefault();
138
+ (event.currentTarget as HTMLSelectElement).focus();
139
+ }
140
+
141
+ function onKeydown(event: KeyboardEvent): void {
142
+ if (!props.readonly) return;
143
+ if (event.key === 'Tab' || event.key === 'Escape') return;
144
+ if (event.metaKey || event.ctrlKey) return;
145
+ event.preventDefault();
146
+ }
114
147
  </script>
115
148
 
116
149
  <style>
@@ -38,6 +38,11 @@
38
38
  cursor: not-allowed;
39
39
  }
40
40
 
41
+ .pui-field[data-readonly='true'] .pui-select__control {
42
+ background: var(--bg-clr-surface-2);
43
+ cursor: default;
44
+ }
45
+
41
46
  .pui-select__caret {
42
47
  position: absolute;
43
48
  right: 0.85em;
@@ -1,5 +1,10 @@
1
1
  <template>
2
- <div class="pui-field" data-layout="inline" :data-status="computedStatus">
2
+ <div
3
+ class="pui-field"
4
+ data-layout="inline"
5
+ :data-readonly="readonly ? 'true' : undefined"
6
+ :data-status="computedStatus"
7
+ >
3
8
  <input
4
9
  v-bind="$attrs"
5
10
  :id="inputId"
@@ -10,14 +15,11 @@
10
15
  :checked="modelValue"
11
16
  :disabled="disabled"
12
17
  :aria-checked="modelValue ? 'true' : 'false'"
18
+ :aria-readonly="readonly ? 'true' : undefined"
13
19
  :aria-invalid="hasErrors ? 'true' : undefined"
14
20
  :aria-describedby="describedBy"
15
- @change="
16
- emit(
17
- 'update:modelValue',
18
- ($event.target as HTMLInputElement).checked,
19
- )
20
- "
21
+ @click="onClick"
22
+ @change="onChange"
21
23
  />
22
24
  <label class="pui-field__label" :for="inputId">{{ label }}</label>
23
25
  <p v-if="hint" :id="hintId" class="pui-field__hint">{{ hint }}</p>
@@ -45,6 +47,8 @@ const props = withDefaults(
45
47
  modelValue?: boolean;
46
48
  name?: string;
47
49
  disabled?: boolean;
50
+ /** Read-only: `aria-readonly`, still focusable, but toggling is ignored. */
51
+ readonly?: boolean;
48
52
  hint?: string;
49
53
  errors?: string[];
50
54
  status?: 'error' | 'success';
@@ -54,6 +58,7 @@ const props = withDefaults(
54
58
  modelValue: false,
55
59
  name: undefined,
56
60
  disabled: false,
61
+ readonly: false,
57
62
  hint: undefined,
58
63
  errors: () => [],
59
64
  status: undefined,
@@ -82,6 +87,17 @@ const describedBy = computed(() => {
82
87
  if (hasErrors.value) ids.push(errorsId);
83
88
  return ids.length > 0 ? ids.join(' ') : undefined;
84
89
  });
90
+
91
+ // Cancelling `click` keeps the native checked state and fires no `change`;
92
+ // the label's forwarded click and the Space key arrive here too.
93
+ function onClick(event: MouseEvent): void {
94
+ if (props.readonly) event.preventDefault();
95
+ }
96
+
97
+ function onChange(event: Event): void {
98
+ if (props.readonly) return;
99
+ emit('update:modelValue', (event.target as HTMLInputElement).checked);
100
+ }
85
101
  </script>
86
102
 
87
103
  <style>
@@ -47,6 +47,10 @@
47
47
  cursor: not-allowed;
48
48
  }
49
49
 
50
+ .pui-field[data-readonly='true'] .pui-switch {
51
+ cursor: default;
52
+ }
53
+
50
54
  /* Status-driven recolour from the parent pui-field wrapper. */
51
55
  .pui-field[data-status='error'] .pui-switch {
52
56
  border-color: var(--border-clr-danger);
@@ -56,9 +60,19 @@
56
60
  background: var(--bg-clr-success, var(--bg-clr-brand));
57
61
  }
58
62
 
63
+ /* The label is the click target for the switch, so it reads as one. */
64
+ .pui-field:has(.pui-switch) .pui-field__label {
65
+ cursor: pointer;
66
+ user-select: none;
67
+ }
68
+
59
69
  /* Disable the cursor + label colour when the input is disabled. */
60
70
  .pui-field:has(.pui-switch:disabled) .pui-field__label {
61
71
  color: var(--text-clr-muted);
62
72
  cursor: not-allowed;
63
73
  }
74
+
75
+ .pui-field[data-readonly='true']:has(.pui-switch) .pui-field__label {
76
+ cursor: default;
77
+ }
64
78
  }