@pgcorp/ui-kit 0.5.0 → 0.6.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 (28) hide show
  1. package/package.json +1 -1
  2. package/src/components/shared/_internal/SHeaderSurface.css +98 -0
  3. package/src/components/shared/_internal/SHeaderSurface.vue +80 -0
  4. package/src/components/shared/_internal/useRadioGroup.ts +106 -0
  5. package/src/components/shared/containers/SPageHeader.vue +13 -20
  6. package/src/components/shared/controls/SCheckbox.vue +14 -43
  7. package/src/components/shared/controls/SChoiceCards.vue +16 -39
  8. package/src/components/shared/controls/SField.css +0 -12
  9. package/src/components/shared/controls/SField.vue +14 -10
  10. package/src/components/shared/controls/SFieldGroup.css +1 -19
  11. package/src/components/shared/controls/SFieldGroup.vue +13 -10
  12. package/src/components/shared/controls/SFieldLabel.css +0 -1
  13. package/src/components/shared/controls/SFieldLabel.vue +2 -1
  14. package/src/components/shared/controls/SSegmentedControl.vue +15 -43
  15. package/src/components/shared/controls/SSwitch.vue +14 -32
  16. package/src/components/shared/data-display/SBadge.vue +41 -18
  17. package/src/components/shared/data-display/SChip.vue +22 -10
  18. package/src/components/shared/data-display/SSectionHeader.vue +17 -21
  19. package/src/components/shared/database/SDataGrid.vue +13 -0
  20. package/src/internal/fieldPresentation.ts +28 -0
  21. package/src/internal/pillSurface.ts +92 -0
  22. package/src/internal/useBinaryInput.ts +76 -0
  23. package/src/styles/style.css +114 -0
  24. package/src/styles/tokens.css +31 -0
  25. package/src/components/shared/containers/SPageHeader.css +0 -82
  26. package/src/components/shared/data-display/SBadge.css +0 -73
  27. package/src/components/shared/data-display/SChip.css +0 -59
  28. package/src/components/shared/data-display/SSectionHeader.css +0 -84
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pgcorp/ui-kit",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Typed Vue 3 design system with accessible components, semantic themes, and workbench patterns.",
5
5
  "type": "module",
6
6
  "types": "./index.ts",
@@ -0,0 +1,98 @@
1
+ @reference "../../../styles/reference.css";
2
+
3
+ .s-page-header,
4
+ .s-section-header {
5
+ @apply flex min-w-0 items-start justify-between;
6
+ }
7
+
8
+ .s-page-header {
9
+ @apply gap-4 border border-surface-200 bg-surface-0 p-5 shadow-sm;
10
+ @apply dark:border-surface-700 dark:bg-surface-900;
11
+ border-radius: var(--radius-sm);
12
+ }
13
+
14
+ .s-section-header { @apply flex-wrap gap-3; }
15
+
16
+ .s-page-header[data-appearance='plain'] {
17
+ @apply border-x-0 border-t-0 bg-transparent px-0 pt-0 shadow-none dark:bg-transparent;
18
+ border-radius: 0;
19
+ }
20
+
21
+ .s-page-header[data-size='compact'] { @apply p-4; }
22
+ .s-page-header[data-appearance='plain'][data-size='compact'] { @apply px-0 pt-0; }
23
+
24
+ .s-page-header[data-appearance='bare'] {
25
+ @apply border-0 bg-transparent p-0 shadow-none dark:bg-transparent;
26
+ border-radius: 0;
27
+ }
28
+
29
+ .s-section-header[data-spacing-after='sm'] { @apply mb-2; }
30
+ .s-section-header[data-spacing-after='md'] { @apply mb-3; }
31
+ .s-section-header[data-divider='true'] { @apply border-b border-surface-200 pb-3 dark:border-surface-700; }
32
+ .s-section-header[data-padding='sm'] { @apply p-2; }
33
+ .s-section-header[data-padding='md'] { @apply p-4; }
34
+ .s-section-header[data-divider='true'][data-padding='sm'] { @apply pb-2; }
35
+ .s-section-header[data-divider='true'][data-padding='md'] { @apply pb-4; }
36
+
37
+ .s-page-header__copy,
38
+ .s-section-header__copy { @apply min-w-0; }
39
+ .s-section-header__copy { @apply flex-1; }
40
+
41
+ .s-page-header__eyebrow,
42
+ .s-section-header__eyebrow { @apply text-xs uppercase text-surface-500 dark:text-surface-400; }
43
+ .s-page-header__eyebrow { @apply font-medium; }
44
+ .s-section-header__eyebrow { @apply font-semibold; }
45
+
46
+ .s-page-header__title,
47
+ .s-section-header__title { @apply m-0 font-semibold text-surface-900 dark:text-surface-50; }
48
+ .s-page-header__title { @apply text-2xl leading-tight; }
49
+
50
+ .s-page-header__title[data-presentation='assistive'] {
51
+ position: absolute;
52
+ width: 1px;
53
+ height: 1px;
54
+ padding: 0;
55
+ margin: -1px;
56
+ clip-path: inset(50%);
57
+ white-space: nowrap;
58
+ border: 0;
59
+ }
60
+
61
+ .s-page-header[data-size='compact'] .s-page-header__title { @apply text-xl; }
62
+ .s-section-header__title { @apply break-words text-base; }
63
+ .s-section-header[data-size='sm'] .s-section-header__title { @apply text-sm; }
64
+ .s-section-header[data-variant='display'] .s-section-header__title {
65
+ @apply text-xl font-semibold uppercase tracking-[0.22em] text-surface-700 dark:text-surface-200;
66
+ }
67
+
68
+ .s-page-header__eyebrow + .s-page-header__title,
69
+ .s-section-header__eyebrow + .s-section-header__title { @apply mt-1; }
70
+
71
+ .s-page-header__description,
72
+ .s-section-header__description,
73
+ .s-section-header__meta { @apply text-surface-500 dark:text-surface-400; }
74
+ .s-page-header__description { @apply mt-2 max-w-4xl text-sm; }
75
+ .s-section-header__description,
76
+ .s-section-header__meta {
77
+ @apply mt-1 min-w-0 break-words text-xs;
78
+ overflow-wrap: anywhere;
79
+ }
80
+
81
+ .s-page-header__actions,
82
+ .s-section-header__trailing,
83
+ .s-section-header__badges,
84
+ .s-section-header__actions { @apply flex min-w-0 flex-wrap items-center justify-end gap-2; }
85
+ .s-page-header__actions { @apply shrink-0; }
86
+ .s-page-header[data-actions-layout='stacked'] { @apply flex-col; }
87
+
88
+ .s-page-header[data-actions-layout='stacked'] .s-page-header__actions,
89
+ .s-section-header[data-trailing-layout='stacked'] .s-section-header__trailing,
90
+ .s-section-header[data-trailing-layout='stacked'] .s-section-header__actions { @apply w-full justify-start; }
91
+
92
+ @media (max-width: 639px) {
93
+ .s-page-header[data-actions-layout='responsive'] { @apply flex-col; }
94
+
95
+ .s-page-header[data-actions-layout='responsive'] .s-page-header__actions,
96
+ .s-section-header[data-trailing-layout='responsive'] .s-section-header__trailing,
97
+ .s-section-header[data-trailing-layout='responsive'] .s-section-header__actions { @apply w-full justify-start; }
98
+ }
@@ -0,0 +1,80 @@
1
+ <template>
2
+ <header
3
+ :class="kind === 'page' ? 's-page-header' : 's-section-header'"
4
+ :data-kind="kind"
5
+ :data-actions-layout="kind === 'page' ? trailingLayout : undefined"
6
+ :data-trailing-layout="kind === 'section' ? trailingLayout : undefined"
7
+ :data-appearance="kind === 'page' ? appearance : undefined"
8
+ :data-size="size"
9
+ :data-variant="kind === 'section' ? variant : undefined"
10
+ :data-divider="kind === 'section' && divider ? 'true' : undefined"
11
+ :data-padding="kind === 'section' ? padding : undefined"
12
+ :data-spacing-after="kind === 'section' ? spacingAfter : undefined"
13
+ >
14
+ <div :class="kind === 'page' ? 's-page-header__copy' : 's-section-header__copy'">
15
+ <div
16
+ v-if="eyebrow"
17
+ :class="kind === 'page' ? 's-page-header__eyebrow' : 's-section-header__eyebrow'"
18
+ >{{ eyebrow }}</div>
19
+ <component
20
+ :is="`h${headingLevel}`"
21
+ :class="kind === 'page' ? 's-page-header__title' : 's-section-header__title'"
22
+ :data-presentation="titlePresentation"
23
+ >{{ title }}</component>
24
+ <p
25
+ v-if="description"
26
+ :class="kind === 'page' ? 's-page-header__description' : 's-section-header__description'"
27
+ >{{ description }}</p>
28
+ <p v-if="kind === 'section' && meta" class="s-section-header__meta">{{ meta }}</p>
29
+ </div>
30
+
31
+ <div v-if="hasTrailing" :class="kind === 'page' ? 's-page-header__actions' : 's-section-header__trailing'">
32
+ <template v-if="kind === 'page'">
33
+ <slot name="actions" />
34
+ </template>
35
+ <template v-else>
36
+ <div v-if="$slots.badges" class="s-section-header__badges"><slot name="badges" /></div>
37
+ <div v-if="$slots.actions" class="s-section-header__actions"><slot name="actions" /></div>
38
+ </template>
39
+ </div>
40
+ </header>
41
+ </template>
42
+
43
+ <script setup lang="ts">
44
+ import { computed, useSlots } from 'vue'
45
+
46
+ export interface Props {
47
+ kind: 'page' | 'section'
48
+ title: string
49
+ eyebrow?: string | null
50
+ description?: string | null
51
+ meta?: string | null
52
+ headingLevel: 1 | 2 | 3 | 4
53
+ titlePresentation?: 'visible' | 'assistive'
54
+ appearance?: 'bare' | 'plain' | 'panel'
55
+ size: 'compact' | 'default' | 'sm' | 'md'
56
+ variant?: 'default' | 'display'
57
+ divider?: boolean
58
+ padding?: 'none' | 'sm' | 'md'
59
+ spacingAfter?: 'none' | 'sm' | 'md'
60
+ trailingLayout: 'responsive' | 'inline' | 'stacked'
61
+ }
62
+
63
+ const props = withDefaults(defineProps<Props>(), {
64
+ eyebrow: undefined,
65
+ description: undefined,
66
+ meta: undefined,
67
+ titlePresentation: 'visible',
68
+ appearance: 'panel',
69
+ variant: 'default',
70
+ divider: false,
71
+ padding: 'none',
72
+ spacingAfter: 'none',
73
+ })
74
+ const slots = useSlots()
75
+ const hasTrailing = computed(() => props.kind === 'page'
76
+ ? Boolean(slots.actions)
77
+ : Boolean(slots.badges || slots.actions))
78
+ </script>
79
+
80
+ <style lang="postcss" src="./SHeaderSurface.css" scoped></style>
@@ -0,0 +1,106 @@
1
+ import { computed } from 'vue'
2
+ import type { ComponentPublicInstance, ComputedRef, Ref } from 'vue'
3
+
4
+ import {
5
+ selectionKeyToken,
6
+ useSelectionRoving,
7
+ } from './useSelectionRoving'
8
+ import type {
9
+ SelectionKey,
10
+ SelectionRovingOption,
11
+ } from './useSelectionRoving'
12
+
13
+ /** Опция общего radio-group runtime. / An option accepted by the shared radio-group runtime. */
14
+ export type RadioGroupOption<T extends SelectionKey> = SelectionRovingOption<T>
15
+
16
+ /** ARIA-контракт контейнера radio-group. / ARIA contract for a radio-group container. */
17
+ export interface RadioGroupAriaContract {
18
+ labelledBy: string
19
+ describedBy?: string
20
+ required?: boolean
21
+ invalid?: boolean
22
+ }
23
+
24
+ /** Общий runtime-контракт radio-group без визуального представления. / Shared radio-group runtime without presentation ownership. */
25
+ export interface RadioGroupRuntime<T extends SelectionKey> {
26
+ focusableValue: ComputedRef<T | undefined>
27
+ groupBindings: (contract: RadioGroupAriaContract) => Record<string, unknown>
28
+ optionBindings: (option: RadioGroupOption<T>) => Record<string, unknown>
29
+ optionRef: (value: T) => (element: Element | ComponentPublicInstance | null) => void
30
+ optionToken: (value: T) => string
31
+ }
32
+
33
+ function resolveNativeElement(
34
+ element: Element | ComponentPublicInstance | null,
35
+ ): HTMLElement | null {
36
+ return element instanceof HTMLElement ? element : null
37
+ }
38
+
39
+ /**
40
+ * Централизует identity, ARIA, readonly/disabled и roving-focus radio-group.
41
+ * Centralizes identity, ARIA, readonly/disabled, and roving focus for a radio group.
42
+ */
43
+ export function useRadioGroup<T extends SelectionKey>(config: {
44
+ component: string
45
+ options: ComputedRef<readonly RadioGroupOption<T>[]>
46
+ selectedValue: Ref<T | null | undefined>
47
+ disabled: ComputedRef<boolean>
48
+ readonly: ComputedRef<boolean>
49
+ update: (value: T) => void
50
+ orientation?: 'horizontal' | 'vertical' | 'both'
51
+ }): RadioGroupRuntime<T> {
52
+ const rovingOptions = computed<readonly RadioGroupOption<T>[]>(() => (
53
+ config.disabled.value
54
+ ? config.options.value.map((option) => ({ ...option, disabled: true }))
55
+ : config.options.value
56
+ ))
57
+ const select = (value: T): void => {
58
+ const option = config.options.value.find((candidate) => candidate.value === value)
59
+ if (config.disabled.value || config.readonly.value || option?.disabled) return
60
+ config.update(value)
61
+ }
62
+ const roving = useSelectionRoving({
63
+ options: rovingOptions,
64
+ selectedValue: config.selectedValue,
65
+ orientation: config.orientation,
66
+ select,
67
+ component: config.component,
68
+ })
69
+
70
+ const groupBindings = (contract: RadioGroupAriaContract): Record<string, unknown> => ({
71
+ role: 'radiogroup',
72
+ 'aria-labelledby': contract.labelledBy,
73
+ 'aria-describedby': contract.describedBy,
74
+ 'aria-required': contract.required || undefined,
75
+ 'aria-invalid': contract.invalid || undefined,
76
+ 'aria-disabled': config.disabled.value || undefined,
77
+ 'aria-readonly': config.readonly.value || undefined,
78
+ })
79
+ const optionBindings = (option: RadioGroupOption<T>): Record<string, unknown> => ({
80
+ type: 'button',
81
+ role: 'radio',
82
+ disabled: config.disabled.value || option.disabled,
83
+ 'aria-checked': option.value === config.selectedValue.value,
84
+ tabindex: option.value === roving.focusableValue.value ? 0 : -1,
85
+ onClick: () => select(option.value),
86
+ onKeydown: (event: KeyboardEvent) => roving.handleKeydown(event, option.value),
87
+ })
88
+ const optionRef = (value: T) => (
89
+ element: Element | ComponentPublicInstance | null,
90
+ ): void => {
91
+ roving.register(value, resolveNativeElement(element))
92
+ }
93
+ const optionToken = (value: T): string => selectionKeyToken(
94
+ config.component,
95
+ value,
96
+ 'option.value',
97
+ )
98
+
99
+ return {
100
+ focusableValue: roving.focusableValue,
101
+ groupBindings,
102
+ optionBindings,
103
+ optionRef,
104
+ optionToken,
105
+ }
106
+ }
@@ -1,5 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import { useOwnedAttrs } from '../../../internal/ownedAttrs'
3
+ import SHeaderSurface from '../_internal/SHeaderSurface.vue'
3
4
 
4
5
  defineOptions({ inheritAttrs: false })
5
6
 
@@ -26,26 +27,18 @@ const ownedAttrs = useOwnedAttrs({ component: 'SPageHeader', owner: 'page header
26
27
  </script>
27
28
 
28
29
  <template>
29
- <header
30
+ <SHeaderSurface
30
31
  v-bind="ownedAttrs.bindings()"
31
- class="s-page-header"
32
- :data-actions-layout="actionsLayout"
33
- :data-appearance="appearance"
34
- :data-size="size"
32
+ kind="page"
33
+ :title="title"
34
+ :description="description"
35
+ :eyebrow="eyebrow"
36
+ :heading-level="headingLevel"
37
+ :appearance="appearance"
38
+ :size="size"
39
+ :trailing-layout="actionsLayout"
40
+ :title-presentation="titlePresentation"
35
41
  >
36
- <div class="s-page-header__copy">
37
- <div v-if="eyebrow" class="s-page-header__eyebrow">{{ eyebrow }}</div>
38
- <component
39
- :is="`h${headingLevel}`"
40
- class="s-page-header__title"
41
- :data-presentation="titlePresentation"
42
- >{{ title }}</component>
43
- <p v-if="description" class="s-page-header__description">{{ description }}</p>
44
- </div>
45
- <div v-if="$slots.actions" class="s-page-header__actions">
46
- <slot name="actions" />
47
- </div>
48
- </header>
42
+ <template v-if="$slots.actions" #actions><slot name="actions" /></template>
43
+ </SHeaderSurface>
49
44
  </template>
50
-
51
- <style lang="postcss" src="./SPageHeader.css" scoped></style>
@@ -43,11 +43,10 @@
43
43
  </template>
44
44
 
45
45
  <script setup lang="ts">
46
- import { computed, onMounted, ref, useId, watch } from 'vue'
47
46
  import { Check, Minus } from '../../icons/sputnigUiIcons'
48
47
  import { mergeIdReferences, useOwnedAttrs } from '../../../internal/ownedAttrs'
49
48
  import { useInteractiveLeafRegistration } from '../../../internal/passiveContentContract'
50
- import { resolveOptionalDomId } from '../../../internal/runtimeContract'
49
+ import { useBinaryInput } from '../../../internal/useBinaryInput'
51
50
  import SField from './SField.vue'
52
51
 
53
52
  defineOptions({ inheritAttrs: false })
@@ -83,48 +82,20 @@ const emit = defineEmits<{
83
82
  }>()
84
83
  const ownedAttrs = useOwnedAttrs({ component: 'SCheckbox', owner: 'native checkbox input' })
85
84
  useInteractiveLeafRegistration({ owner: 'SCheckbox' })
86
-
87
- const generatedInputId = useId()
88
- const inputId = computed(() => resolveOptionalDomId(
89
- 'SCheckbox',
90
- 'id',
91
- props.id,
92
- `s-checkbox-${generatedInputId}`,
93
- ))
94
- const inputEl = ref<HTMLInputElement | null>(null)
95
-
96
- const checkedValue = computed(() => {
97
- if (typeof props.modelValue !== 'boolean') {
98
- throw new TypeError('SCheckbox: modelValue must be boolean')
99
- }
100
- return props.modelValue
85
+ const {
86
+ checkedValue,
87
+ inputEl,
88
+ inputId,
89
+ onChange,
90
+ onClick,
91
+ } = useBinaryInput({
92
+ owner: 'SCheckbox',
93
+ inputName: 'checkbox',
94
+ props,
95
+ indeterminate: () => props.indeterminate,
96
+ emitUpdate: (value) => emit('update:modelValue', value),
97
+ emitClick: (event) => emit('click', event),
101
98
  })
102
-
103
- function applyIndeterminate(): void {
104
- if (inputEl.value) {
105
- inputEl.value.indeterminate = props.indeterminate && !checkedValue.value
106
- }
107
- }
108
-
109
- onMounted(applyIndeterminate)
110
- watch([() => props.indeterminate, checkedValue], applyIndeterminate)
111
-
112
- function onChange(event: Event): void {
113
- if (props.readonly) return
114
- const target = event.currentTarget
115
- if (!(target instanceof HTMLInputElement)) {
116
- throw new TypeError('SCheckbox: change event owner must be the native checkbox input')
117
- }
118
- emit('update:modelValue', target.checked)
119
- }
120
-
121
- function onClick(event: MouseEvent): void {
122
- if (props.readonly) {
123
- event.preventDefault()
124
- return
125
- }
126
- emit('click', event)
127
- }
128
99
  </script>
129
100
 
130
101
  <style lang="postcss" src="./SCheckbox.css" scoped></style>
@@ -16,26 +16,19 @@
16
16
  :data-columns="columns"
17
17
  :data-density="density"
18
18
  :data-selection-indicator="selectionIndicator"
19
- role="radiogroup"
20
- :aria-labelledby="labelId"
21
- :aria-describedby="describedBy"
22
- :aria-required="required || undefined"
23
- :aria-invalid="invalid || undefined"
24
- :aria-disabled="disabled || undefined"
25
- :aria-readonly="readonly || undefined"
19
+ v-bind="radioGroup.groupBindings({
20
+ labelledBy: labelId,
21
+ describedBy,
22
+ required,
23
+ invalid,
24
+ })"
26
25
  >
27
26
  <button
28
27
  v-for="option in options"
29
- :key="optionToken(option)"
30
- :ref="(element) => roving.register(option.value, resolveNativeElement(element))"
28
+ :key="radioGroup.optionToken(option.value)"
29
+ :ref="radioGroup.optionRef(option.value)"
31
30
  class="s-choice-card"
32
- type="button"
33
- :disabled="disabled || option.disabled"
34
- :aria-checked="option.value === modelValue"
35
- :tabindex="option.value === focusableValue ? 0 : -1"
36
- role="radio"
37
- @click="select(option.value)"
38
- @keydown="roving.handleKeydown($event, option.value)"
31
+ v-bind="radioGroup.optionBindings(option)"
39
32
  >
40
33
  <span v-if="option.kicker" class="s-choice-card__kicker">{{ option.kicker }}</span>
41
34
  <span class="s-choice-card__title">{{ option.title }}</span>
@@ -83,10 +76,10 @@ export interface Props<T extends PublicSelectionKey = string> {
83
76
 
84
77
  <script setup lang="ts" generic="T extends SelectionKey = string">
85
78
  import { computed } from 'vue'
86
- import type { ComponentPublicInstance, VNodeChild } from 'vue'
79
+ import type { VNodeChild } from 'vue'
87
80
  import { useOwnedAttrs } from '../../../internal/ownedAttrs'
88
81
  import { useInteractiveLeafRegistration } from '../../../internal/passiveContentContract'
89
- import { selectionKeyToken, useSelectionRoving } from '../_internal/useSelectionRoving'
82
+ import { useRadioGroup } from '../_internal/useRadioGroup'
90
83
  import type { SelectionKey } from '../_internal/useSelectionRoving'
91
84
  import SFieldGroup from './SFieldGroup.vue'
92
85
 
@@ -116,30 +109,14 @@ defineSlots<{
116
109
  }>()
117
110
  const ownedAttrs = useOwnedAttrs({ component: 'SChoiceCards', owner: 'field group' })
118
111
  useInteractiveLeafRegistration({ owner: 'SChoiceCards' })
119
- const select = (value: T): void => {
120
- if (props.disabled || props.readonly) return
121
- emit('update:modelValue', value)
122
- }
123
- const rovingOptions = computed<readonly ChoiceCardOption<T>[]>(() => (
124
- props.disabled
125
- ? props.options.map((option) => ({ ...option, disabled: true }))
126
- : props.options
127
- ))
128
- const roving = useSelectionRoving({
129
- options: rovingOptions,
112
+ const radioGroup = useRadioGroup({
113
+ options: computed(() => props.options),
130
114
  selectedValue: computed(() => props.modelValue),
131
- select,
115
+ disabled: computed(() => props.disabled),
116
+ readonly: computed(() => props.readonly),
117
+ update: (value) => emit('update:modelValue', value),
132
118
  component: 'SChoiceCards',
133
119
  })
134
- const focusableValue = roving.focusableValue
135
- const optionToken = (option: ChoiceCardOption<T>): string => selectionKeyToken(
136
- 'SChoiceCards',
137
- option.value,
138
- 'option.value',
139
- )
140
- const resolveNativeElement = (element: Element | ComponentPublicInstance | null): HTMLElement | null => (
141
- element instanceof HTMLElement ? element : null
142
- )
143
120
  </script>
144
121
 
145
122
  <style lang="postcss" src="./SChoiceCards.css" scoped></style>
@@ -53,24 +53,12 @@
53
53
  @apply flex items-center;
54
54
  }
55
55
 
56
- .s-field__required {
57
- @apply ml-0.5 text-danger-600 dark:text-danger-400;
58
- }
59
-
60
- .s-field__help,
61
- .s-field__error {
62
- @apply mt-1 min-w-0 whitespace-normal text-xs;
63
- overflow-wrap: anywhere;
64
- }
65
-
66
56
  .s-field__help {
67
57
  grid-area: help;
68
- @apply text-surface-500 dark:text-surface-400;
69
58
  }
70
59
 
71
60
  .s-field__error {
72
61
  grid-area: error;
73
- @apply text-danger-600 dark:text-danger-400;
74
62
  }
75
63
 
76
64
  .s-field[data-disabled='true'] .s-field__label,
@@ -10,7 +10,7 @@
10
10
  >
11
11
  <label v-if="hasLabel" :id="labelId" class="s-field__label" :for="controlId">
12
12
  <slot name="label">{{ label }}</slot>
13
- <span v-if="required" class="s-field__required" aria-hidden="true">*</span>
13
+ <span v-if="required" :class="['s-field__required', FIELD_REQUIRED_CLASS]" aria-hidden="true">*</span>
14
14
  </label>
15
15
 
16
16
  <div class="s-field__control">
@@ -25,10 +25,10 @@
25
25
  />
26
26
  </div>
27
27
 
28
- <p v-if="helpText || $slots.help" :id="helpId" class="s-field__help">
28
+ <p v-if="helpText || $slots.help" :id="helpId" :class="['s-field__help', FIELD_HELP_CLASS]">
29
29
  <slot name="help">{{ helpText }}</slot>
30
30
  </p>
31
- <p v-if="isInvalid && (errorMessage || $slots.error)" :id="errorId" class="s-field__error">
31
+ <p v-if="isInvalid && (errorMessage || $slots.error)" :id="errorId" :class="['s-field__error', FIELD_ERROR_CLASS]">
32
32
  <slot name="error">{{ errorMessage }}</slot>
33
33
  </p>
34
34
  </div>
@@ -36,6 +36,12 @@
36
36
 
37
37
  <script setup lang="ts">
38
38
  import { computed, useSlots } from 'vue'
39
+ import {
40
+ FIELD_ERROR_CLASS,
41
+ FIELD_HELP_CLASS,
42
+ FIELD_REQUIRED_CLASS,
43
+ useFieldSupportingText,
44
+ } from '../../../internal/fieldPresentation'
39
45
  import { useOwnedAttrs } from '../../../internal/ownedAttrs'
40
46
 
41
47
  defineOptions({ inheritAttrs: false })
@@ -76,13 +82,11 @@ const ownedAttrs = useOwnedAttrs({ component: 'SField', owner: 'field root' })
76
82
  const isInvalid = computed(() => props.invalid)
77
83
  const hasLabel = computed(() => Boolean(props.label || slots.label))
78
84
  const labelId = computed(() => `${props.controlId}-label`)
79
- const helpId = computed(() => `${props.controlId}-help`)
80
- const errorId = computed(() => `${props.controlId}-error`)
81
- const describedBy = computed(() => {
82
- const ids: string[] = []
83
- if (props.helpText || slots.help) ids.push(helpId.value)
84
- if (isInvalid.value && (props.errorMessage || slots.error)) ids.push(errorId.value)
85
- return ids.length > 0 ? ids.join(' ') : undefined
85
+ const fieldId = computed(() => props.controlId)
86
+ const { describedBy, errorId, helpId } = useFieldSupportingText({
87
+ baseId: fieldId,
88
+ hasHelp: () => Boolean(props.helpText || slots.help),
89
+ hasError: () => Boolean(isInvalid.value && (props.errorMessage || slots.error)),
86
90
  })
87
91
  </script>
88
92
 
@@ -8,10 +8,6 @@
8
8
  @apply mb-2 min-w-0 text-sm font-medium text-surface-700 dark:text-surface-300;
9
9
  }
10
10
 
11
- .s-field-group__required {
12
- @apply ml-0.5 text-danger-600 dark:text-danger-400;
13
- }
14
-
15
11
  .s-field-group__controls {
16
12
  @apply grid min-w-0 gap-2;
17
13
  }
@@ -32,23 +28,9 @@
32
28
  @apply grid-cols-4;
33
29
  }
34
30
 
35
- .s-field-group__help,
36
- .s-field-group__error {
37
- @apply mt-1 min-w-0 whitespace-normal text-xs;
38
- overflow-wrap: anywhere;
39
- }
40
-
41
- .s-field-group__help {
42
- @apply text-surface-500 dark:text-surface-400;
43
- }
44
-
45
- .s-field-group__error {
46
- @apply text-danger-600 dark:text-danger-400;
47
- }
48
-
49
31
  .s-field-group[data-disabled='true'] .s-field-group__legend,
50
32
  .s-field-group[data-readonly='true'] .s-field-group__legend {
51
- @apply text-surface-400 dark:text-surface-500;
33
+ color: var(--s-field-disabled-foreground);
52
34
  }
53
35
 
54
36
  @media (max-width: 40rem) {
@@ -14,7 +14,7 @@
14
14
  >
15
15
  <legend :id="labelId" class="s-field-group__legend">
16
16
  <slot name="label">{{ validatedLabel }}</slot>
17
- <span v-if="required" class="s-field-group__required" aria-hidden="true">*</span>
17
+ <span v-if="required" :class="['s-field-group__required', FIELD_REQUIRED_CLASS]" aria-hidden="true">*</span>
18
18
  </legend>
19
19
 
20
20
  <div class="s-field-group__controls">
@@ -29,10 +29,10 @@
29
29
  />
30
30
  </div>
31
31
 
32
- <p v-if="helpText || $slots.help" :id="helpId" class="s-field-group__help">
32
+ <p v-if="helpText || $slots.help" :id="helpId" :class="['s-field-group__help', FIELD_HELP_CLASS]">
33
33
  <slot name="help">{{ helpText }}</slot>
34
34
  </p>
35
- <p v-if="invalid && (errorMessage || $slots.error)" :id="errorId" class="s-field-group__error">
35
+ <p v-if="invalid && (errorMessage || $slots.error)" :id="errorId" :class="['s-field-group__error', FIELD_ERROR_CLASS]">
36
36
  <slot name="error">{{ errorMessage }}</slot>
37
37
  </p>
38
38
  </fieldset>
@@ -40,6 +40,12 @@
40
40
 
41
41
  <script setup lang="ts">
42
42
  import { computed, useId, useSlots } from 'vue'
43
+ import {
44
+ FIELD_ERROR_CLASS,
45
+ FIELD_HELP_CLASS,
46
+ FIELD_REQUIRED_CLASS,
47
+ useFieldSupportingText,
48
+ } from '../../../internal/fieldPresentation'
43
49
  import { mergeIdReferences, useOwnedAttrs } from '../../../internal/ownedAttrs'
44
50
  import {
45
51
  validateBoundedInteger,
@@ -92,13 +98,10 @@ const groupId = computed(() => validateDomId(
92
98
  ))
93
99
  const labelId = computed(() => `${groupId.value}-legend`)
94
100
  const validatedLabel = computed(() => validateNonEmptyString('SFieldGroup', 'label', props.label))
95
- const helpId = computed(() => `${groupId.value}-help`)
96
- const errorId = computed(() => `${groupId.value}-error`)
97
- const describedBy = computed(() => {
98
- const ids: string[] = []
99
- if (props.helpText || slots.help) ids.push(helpId.value)
100
- if (props.invalid && (props.errorMessage || slots.error)) ids.push(errorId.value)
101
- return ids.length > 0 ? ids.join(' ') : undefined
101
+ const { describedBy, errorId, helpId } = useFieldSupportingText({
102
+ baseId: groupId,
103
+ hasHelp: () => Boolean(props.helpText || slots.help),
104
+ hasError: () => Boolean(props.invalid && (props.errorMessage || slots.error)),
102
105
  })
103
106
  const validatedLayout = computed<SFieldGroupLayout>(() => validateExactString(
104
107
  'SFieldGroup',