@yunlefun/vue 0.2.0 → 0.4.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 (65) hide show
  1. package/README.md +2 -0
  2. package/components/YlfAccordion.vue +1 -1
  3. package/components/YlfAvatar.vue +2 -1
  4. package/components/YlfDialog.vue +23 -2
  5. package/components/YlfDropdownMenu.vue +20 -9
  6. package/components/YlfPopover.vue +5 -2
  7. package/components/YlfProgress.vue +29 -5
  8. package/components/YlfRadioGroup.vue +20 -2
  9. package/components/YlfSegmentedControl.vue +180 -0
  10. package/components/YlfSelect.vue +47 -13
  11. package/components/YlfSlider.vue +32 -2
  12. package/components/YlfTabs.vue +1 -1
  13. package/components/YlfTooltip.vue +8 -3
  14. package/components/accordion/demo.vue +1 -1
  15. package/components/accordion/index.md +2 -0
  16. package/components/avatar/index.md +3 -1
  17. package/components/checkbox/demo.vue +10 -9
  18. package/components/checkbox/index.md +2 -0
  19. package/components/dialog/index.md +9 -6
  20. package/components/dropdown-menu/index.md +9 -3
  21. package/components/popover/demo.vue +1 -1
  22. package/components/popover/index.md +8 -5
  23. package/components/progress/index.md +3 -1
  24. package/components/radio/demo.vue +1 -1
  25. package/components/radio/index.md +2 -0
  26. package/components/segmented-control/demo.vue +21 -0
  27. package/components/segmented-control/index.md +41 -0
  28. package/components/segmented-control.ts +8 -0
  29. package/components/select/index.md +10 -0
  30. package/components/slider/demo.vue +2 -2
  31. package/components/slider/index.md +15 -7
  32. package/components/switch/index.md +2 -0
  33. package/components/tabs/index.md +2 -0
  34. package/components/tooltip/index.md +11 -5
  35. package/dist/components/YlfAccordion.vue.d.ts +35 -0
  36. package/dist/components/YlfAiPromptLauncher.vue.d.ts +25 -0
  37. package/dist/components/YlfAvatar.vue.d.ts +14 -0
  38. package/dist/components/YlfBadge.vue.d.ts +29 -0
  39. package/dist/components/YlfBottomMenu.vue.d.ts +19 -0
  40. package/dist/components/YlfBottomMenuItem.vue.d.ts +22 -0
  41. package/dist/components/YlfButton.vue.d.ts +48 -0
  42. package/dist/components/YlfCard.vue.d.ts +28 -0
  43. package/dist/components/YlfCheckbox.vue.d.ts +18 -0
  44. package/dist/components/YlfDialog.vue.d.ts +37 -0
  45. package/dist/components/YlfDropdownMenu.vue.d.ts +34 -0
  46. package/dist/components/YlfLogo.vue.d.ts +13 -0
  47. package/dist/components/YlfPopover.vue.d.ts +31 -0
  48. package/dist/components/YlfProgress.vue.d.ts +17 -0
  49. package/dist/components/YlfRadioGroup.vue.d.ts +24 -0
  50. package/dist/components/YlfSegmentedControl.vue.d.ts +35 -0
  51. package/dist/components/YlfSelect.vue.d.ts +43 -0
  52. package/dist/components/YlfSeparator.vue.d.ts +16 -0
  53. package/dist/components/YlfSlider.vue.d.ts +29 -0
  54. package/dist/components/YlfSwitch.vue.d.ts +24 -0
  55. package/dist/components/YlfTabs.vue.d.ts +32 -0
  56. package/dist/components/YlfTooltip.vue.d.ts +26 -0
  57. package/dist/components/ai-prompt.d.ts +33 -0
  58. package/dist/components/ai-provider-logos.d.ts +3 -0
  59. package/dist/components/bottom-menu/index.d.ts +20 -0
  60. package/dist/components/segmented-control.d.ts +8 -0
  61. package/dist/components/theme.d.ts +4 -0
  62. package/dist/index.d.cts +10 -1
  63. package/dist/index.d.mts +10 -1
  64. package/dist/index.d.ts +10 -1
  65. package/package.json +6 -2
package/README.md CHANGED
@@ -38,6 +38,8 @@ export default defineNuxtConfig({
38
38
 
39
39
  根入口目前提供类型和组件目录信息,组件通过 `components/*.vue` 子路径或 Nuxt 模块使用;没有公开的 `YlfResolver`。
40
40
 
41
+ 组件子路径提供预生成类型声明,运行时继续使用原始 SFC。现有 15 个 Reka UI 封装的验收范围见[组件验收](../guide/component-acceptance.md)。
42
+
41
43
  更多内容见 [Vue 接入](./index.md)、[子包职责](../guide/packages.md)与[设计体系](../guide/design-system.md)。
42
44
 
43
45
  ## AI 提示词入口
@@ -9,7 +9,7 @@ interface AccordionItemData {
9
9
  }
10
10
 
11
11
  withDefaults(defineProps<{
12
- items: AccordionItemData[]
12
+ items: readonly AccordionItemData[]
13
13
  type?: 'single' | 'multiple'
14
14
  collapsible?: boolean
15
15
  }>(), {
@@ -10,13 +10,14 @@ withDefaults(defineProps<{
10
10
  }>(), {
11
11
  size: 'md',
12
12
  fallback: '',
13
+ alt: '',
13
14
  })
14
15
  </script>
15
16
 
16
17
  <template>
17
18
  <AvatarRoot class="ylf-avatar" :class="`ylf-avatar--${size}`">
18
19
  <AvatarImage v-if="src" :src="src" :alt="alt" class="ylf-avatar__img" />
19
- <AvatarFallback class="ylf-avatar__fallback" :delay-ms="200">
20
+ <AvatarFallback class="ylf-avatar__fallback" :delay-ms="200" :role="alt || fallback ? 'img' : undefined" :aria-label="alt || fallback || undefined">
20
21
  {{ fallback }}
21
22
  </AvatarFallback>
22
23
  </AvatarRoot>
@@ -13,11 +13,15 @@ import {
13
13
  } from 'reka-ui'
14
14
  import { computed, useSlots } from 'vue'
15
15
 
16
+ defineOptions({ inheritAttrs: false })
17
+
16
18
  const props = withDefaults(defineProps<{
17
19
  title?: string
18
20
  description?: string
19
21
  /** 没有可见标题时供屏幕阅读器使用 */
20
22
  accessibleTitle?: string
23
+ /** Existing container in the same theme boundary. */
24
+ portalTo?: string | HTMLElement
21
25
  }>(), {
22
26
  accessibleTitle: '对话框',
23
27
  })
@@ -37,9 +41,9 @@ const descriptionProps = computed(() => hasDescription.value
37
41
  <slot name="trigger" />
38
42
  </DialogTrigger>
39
43
 
40
- <DialogPortal>
44
+ <DialogPortal :to="portalTo">
41
45
  <DialogOverlay class="ylf-dialog__overlay" />
42
- <DialogContent class="ylf-dialog__content" v-bind="descriptionProps">
46
+ <DialogContent class="ylf-dialog__content" v-bind="{ ...descriptionProps, ...$attrs }">
43
47
  <DialogTitle v-if="hasVisibleTitle" class="ylf-dialog__title">
44
48
  <slot name="title">
45
49
  {{ title }}
@@ -110,6 +114,8 @@ const descriptionProps = computed(() => hasDescription.value
110
114
 
111
115
  .ylf-dialog__title {
112
116
  margin: 0 0 6px;
117
+ padding-right: 32px;
118
+ overflow-wrap: anywhere;
113
119
  font-family: var(--ylf-font-heading, inherit);
114
120
  font-size: 19px;
115
121
  font-weight: 700;
@@ -118,6 +124,7 @@ const descriptionProps = computed(() => hasDescription.value
118
124
 
119
125
  .ylf-dialog__desc {
120
126
  margin: 0 0 16px;
127
+ overflow-wrap: anywhere;
121
128
  font-size: 14px;
122
129
  line-height: 1.6;
123
130
  color: var(--ylf-c-text-2, #475569);
@@ -146,12 +153,26 @@ const descriptionProps = computed(() => hasDescription.value
146
153
  color: var(--ylf-c-text, #0f172a);
147
154
  }
148
155
 
156
+ &:focus-visible {
157
+ outline: 2px solid var(--ylf-c-brand, #2563eb);
158
+ outline-offset: 2px;
159
+ }
160
+
149
161
  svg {
150
162
  width: 18px;
151
163
  height: 18px;
152
164
  }
153
165
  }
154
166
 
167
+ @media (pointer: coarse) {
168
+ .ylf-dialog__close {
169
+ top: 10px;
170
+ right: 10px;
171
+ width: 44px;
172
+ height: 44px;
173
+ }
174
+ }
175
+
155
176
  @keyframes ylf-dialog-overlay-in {
156
177
  from {
157
178
  opacity: 0;
@@ -1,6 +1,15 @@
1
1
  <script lang="ts" setup>
2
2
  import { DropdownMenuContent, DropdownMenuItem, DropdownMenuPortal, DropdownMenuRoot, DropdownMenuSeparator, DropdownMenuTrigger } from 'reka-ui'
3
3
 
4
+ defineOptions({ inheritAttrs: false })
5
+
6
+ defineProps<{
7
+ items: readonly MenuItem[]
8
+ portalTo?: string | HTMLElement
9
+ }>()
10
+
11
+ const emit = defineEmits<{ select: [value: string] }>()
12
+
4
13
  interface MenuItem {
5
14
  label?: string
6
15
  value?: string
@@ -9,27 +18,23 @@ interface MenuItem {
9
18
  separator?: boolean
10
19
  }
11
20
 
12
- defineProps<{
13
- items: MenuItem[]
14
- }>()
15
-
16
- const emit = defineEmits<{ select: [value: string] }>()
21
+ const open = defineModel<boolean>('open', { default: false })
17
22
  </script>
18
23
 
19
24
  <template>
20
- <DropdownMenuRoot>
25
+ <DropdownMenuRoot v-model:open="open">
21
26
  <DropdownMenuTrigger as-child>
22
27
  <slot name="trigger" />
23
28
  </DropdownMenuTrigger>
24
- <DropdownMenuPortal>
25
- <DropdownMenuContent class="ylf-menu" :side-offset="6" align="start">
29
+ <DropdownMenuPortal :to="portalTo">
30
+ <DropdownMenuContent v-bind="$attrs" class="ylf-menu" :side-offset="6" align="start">
26
31
  <template v-for="(it, i) in items" :key="i">
27
32
  <DropdownMenuSeparator v-if="it.separator" class="ylf-menu__sep" />
28
33
  <DropdownMenuItem
29
34
  v-else
30
35
  class="ylf-menu__item"
31
36
  :disabled="it.disabled"
32
- @select="it.value && emit('select', it.value)"
37
+ @select="it.value !== undefined && emit('select', it.value)"
33
38
  >
34
39
  {{ it.label }}
35
40
  </DropdownMenuItem>
@@ -83,6 +88,12 @@ const emit = defineEmits<{ select: [value: string] }>()
83
88
  }
84
89
  }
85
90
 
91
+ @media (pointer: coarse) {
92
+ .ylf-menu__item {
93
+ min-height: 44px;
94
+ }
95
+ }
96
+
86
97
  @keyframes ylf-pop-in {
87
98
  from {
88
99
  opacity: 0;
@@ -1,9 +1,12 @@
1
1
  <script lang="ts" setup>
2
2
  import { PopoverArrow, PopoverContent, PopoverPortal, PopoverRoot, PopoverTrigger } from 'reka-ui'
3
3
 
4
+ defineOptions({ inheritAttrs: false })
5
+
4
6
  withDefaults(defineProps<{
5
7
  side?: 'top' | 'right' | 'bottom' | 'left'
6
8
  align?: 'start' | 'center' | 'end'
9
+ portalTo?: string | HTMLElement
7
10
  }>(), {
8
11
  side: 'bottom',
9
12
  align: 'center',
@@ -17,8 +20,8 @@ const open = defineModel<boolean>('open', { default: false })
17
20
  <PopoverTrigger as-child>
18
21
  <slot name="trigger" />
19
22
  </PopoverTrigger>
20
- <PopoverPortal>
21
- <PopoverContent :side="side" :align="align" :side-offset="8" class="ylf-popover">
23
+ <PopoverPortal :to="portalTo">
24
+ <PopoverContent v-bind="$attrs" :side="side" :align="align" :side-offset="8" class="ylf-popover">
22
25
  <slot />
23
26
  <PopoverArrow class="ylf-popover__arrow" :width="12" :height="6" />
24
27
  </PopoverContent>
@@ -4,7 +4,8 @@ import { ProgressIndicator, ProgressRoot } from 'reka-ui'
4
4
  import { computed } from 'vue'
5
5
 
6
6
  const props = withDefaults(defineProps<{
7
- value?: number
7
+ /** null means that progress is unknown. */
8
+ value?: number | null
8
9
  max?: number
9
10
  /** 进度条填充:brand 主色或 accent 纯色;aurora 是 accent 的旧别名 */
10
11
  variant?: 'brand' | 'accent' | 'aurora'
@@ -16,20 +17,24 @@ const props = withDefaults(defineProps<{
16
17
  tone: 'blue',
17
18
  })
18
19
 
19
- const percent = computed(() => Math.max(0, Math.min(100, (props.value / props.max) * 100)))
20
+ const safeMax = computed(() => Number.isFinite(props.max) && props.max > 0 ? props.max : 100)
21
+ const safeValue = computed(() => props.value === null || !Number.isFinite(props.value)
22
+ ? null
23
+ : Math.max(0, Math.min(safeMax.value, props.value)))
24
+ const percent = computed(() => safeValue.value === null ? null : safeValue.value / safeMax.value * 100)
20
25
  </script>
21
26
 
22
27
  <template>
23
28
  <ProgressRoot
24
- :model-value="value"
25
- :max="max"
29
+ :model-value="safeValue"
30
+ :max="safeMax"
26
31
  class="ylf-progress"
27
32
  :data-ylf-tone="tone"
28
33
  :class="`ylf-progress--${variant}`"
29
34
  >
30
35
  <ProgressIndicator
31
36
  class="ylf-progress__indicator"
32
- :style="{ transform: `translateX(-${100 - percent}%)` }"
37
+ :style="{ transform: percent === null ? undefined : `translateX(-${100 - percent}%)` }"
33
38
  />
34
39
  </ProgressRoot>
35
40
  </template>
@@ -52,6 +57,20 @@ const percent = computed(() => Math.max(0, Math.min(100, (props.value / props.ma
52
57
  transition: transform 0.4s var(--ylf-ease-bounce, cubic-bezier(0.34, 1.56, 0.64, 1));
53
58
  }
54
59
 
60
+ .ylf-progress[data-state='indeterminate'] .ylf-progress__indicator {
61
+ width: 40%;
62
+ animation: ylf-progress-indeterminate 1.4s ease-in-out infinite;
63
+ }
64
+
65
+ @keyframes ylf-progress-indeterminate {
66
+ from {
67
+ transform: translateX(-100%);
68
+ }
69
+ to {
70
+ transform: translateX(250%);
71
+ }
72
+ }
73
+
55
74
  .ylf-progress--accent .ylf-progress__indicator,
56
75
  .ylf-progress--aurora .ylf-progress__indicator {
57
76
  background: var(--ylf-accent, var(--ylf-c-brand, #2563eb));
@@ -61,5 +80,10 @@ const percent = computed(() => Math.max(0, Math.min(100, (props.value / props.ma
61
80
  .ylf-progress__indicator {
62
81
  transition: none;
63
82
  }
83
+
84
+ .ylf-progress[data-state='indeterminate'] .ylf-progress__indicator {
85
+ animation: none;
86
+ transform: translateX(75%);
87
+ }
64
88
  }
65
89
  </style>
@@ -1,6 +1,7 @@
1
1
  <script lang="ts" setup>
2
2
  // 行为 / 可访问性由 reka-ui 提供(role=radiogroup、方向键 roving focus);皮肤走 token。
3
3
  import { RadioGroupIndicator, RadioGroupItem, RadioGroupRoot } from 'reka-ui'
4
+ import { nextTick } from 'vue'
4
5
 
5
6
  interface RadioOption {
6
7
  label: string
@@ -8,8 +9,8 @@ interface RadioOption {
8
9
  disabled?: boolean
9
10
  }
10
11
 
11
- withDefaults(defineProps<{
12
- options: RadioOption[]
12
+ const props = withDefaults(defineProps<{
13
+ options: readonly RadioOption[]
13
14
  disabled?: boolean
14
15
  orientation?: 'vertical' | 'horizontal'
15
16
  }>(), {
@@ -18,6 +19,22 @@ withDefaults(defineProps<{
18
19
  })
19
20
 
20
21
  const model = defineModel<string>()
22
+
23
+ function selectFocusedRadio(event: KeyboardEvent) {
24
+ if (props.disabled || !event.defaultPrevented || !['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key))
25
+ return
26
+ const group = event.currentTarget as HTMLElement
27
+ // Reka 2.10.x defers selection until after focus; a fast keyup can clear its flag.
28
+ // Let Reka move focus and skip disabled items, then synchronize that choice.
29
+ nextTick(() => {
30
+ const focused = document.activeElement
31
+ if (!(focused instanceof HTMLElement) || !group.contains(focused) || focused.getAttribute('role') !== 'radio')
32
+ return
33
+ const option = props.options.find(option => option.value === focused.getAttribute('value'))
34
+ if (option && !option.disabled && model.value !== option.value)
35
+ model.value = option.value
36
+ })
37
+ }
21
38
  </script>
22
39
 
23
40
  <template>
@@ -27,6 +44,7 @@ const model = defineModel<string>()
27
44
  :orientation="orientation"
28
45
  class="ylf-radio-group"
29
46
  :class="`ylf-radio-group--${orientation}`"
47
+ @keydown="selectFocusedRadio"
30
48
  >
31
49
  <RadioGroupItem
32
50
  v-for="opt in options"
@@ -0,0 +1,180 @@
1
+ <script setup lang="ts" generic="T extends string = string">
2
+ import type { YlfSegmentedOption } from './segmented-control'
3
+ import { ToggleGroupItem, ToggleGroupRoot } from 'reka-ui'
4
+ import YlfTooltip from './YlfTooltip.vue'
5
+
6
+ const props = withDefaults(defineProps<{
7
+ options: readonly YlfSegmentedOption<T>[]
8
+ /** Accessible name of the group, including for icon-only controls. */
9
+ label: string
10
+ size?: 'sm' | 'md'
11
+ iconOnly?: boolean
12
+ disabled?: boolean
13
+ }>(), {
14
+ size: 'md',
15
+ iconOnly: false,
16
+ disabled: false,
17
+ })
18
+
19
+ defineSlots<{
20
+ icon?: (props: { option: YlfSegmentedOption<T>, selected: boolean }) => unknown
21
+ }>()
22
+ const model = defineModel<T>({ required: true })
23
+ function updateValue(value: unknown): void {
24
+ const option = props.options.find(option => option.value === value)
25
+ // A view or filter always has one active choice, even when clicked again.
26
+ if (!props.disabled && option && !option.disabled)
27
+ model.value = option.value
28
+ }
29
+ </script>
30
+
31
+ <template>
32
+ <ToggleGroupRoot
33
+ class="ylf-segmented"
34
+ :data-size="size"
35
+ :data-icon-only="iconOnly || undefined"
36
+ type="single"
37
+ orientation="horizontal"
38
+ :aria-label="label"
39
+ :disabled="disabled"
40
+ :model-value="model"
41
+ @update:model-value="updateValue"
42
+ >
43
+ <YlfTooltip v-for="option in options" :key="option.value" :content="option.label" :disabled="!iconOnly || disabled || option.disabled">
44
+ <ToggleGroupItem
45
+ class="ylf-segmented__item"
46
+ :value="option.value"
47
+ :disabled="option.disabled"
48
+ :aria-label="option.ariaLabel ?? option.label"
49
+ >
50
+ <span v-if="$slots.icon" class="ylf-segmented__icon" aria-hidden="true">
51
+ <slot name="icon" :option="option" :selected="model === option.value" />
52
+ </span>
53
+ <span v-if="!iconOnly || !$slots.icon" class="ylf-segmented__label">{{ option.label }}</span>
54
+ <span v-if="option.count !== undefined && !iconOnly" class="ylf-segmented__count">{{ option.count }}</span>
55
+ </ToggleGroupItem>
56
+ </YlfTooltip>
57
+ </ToggleGroupRoot>
58
+ </template>
59
+
60
+ <style>
61
+ .ylf-segmented {
62
+ --ylf-segmented-item-size: 36px;
63
+ display: inline-flex;
64
+ align-items: center;
65
+ flex: none;
66
+ box-sizing: border-box;
67
+ max-width: 100%;
68
+ gap: 2px;
69
+ padding: 3px;
70
+ border: 1px solid var(--ylf-c-border, #e2e8f0);
71
+ border-radius: var(--ylf-radius-sm, 10px);
72
+ background: var(--ylf-c-bg-soft, #f1f5f9);
73
+ font-family: var(--ylf-font-body, inherit);
74
+ }
75
+
76
+ .ylf-segmented[data-size='sm'] {
77
+ --ylf-segmented-item-size: 26px;
78
+ }
79
+
80
+ .ylf-segmented__item {
81
+ display: inline-flex;
82
+ align-items: center;
83
+ justify-content: center;
84
+ flex: 0 0 auto;
85
+ box-sizing: border-box;
86
+ min-width: var(--ylf-segmented-item-size);
87
+ height: var(--ylf-segmented-item-size);
88
+ gap: 6px;
89
+ padding: 0 10px;
90
+ border: 1px solid transparent;
91
+ border-radius: calc(var(--ylf-radius-sm, 10px) - 4px);
92
+ background: transparent;
93
+ color: var(--ylf-c-text-2, #475569);
94
+ font: inherit;
95
+ font-size: var(--ylf-text-sm, 14px);
96
+ font-weight: 500;
97
+ line-height: 1;
98
+ white-space: nowrap;
99
+ cursor: pointer;
100
+ transition:
101
+ background var(--ylf-duration-fast, 160ms),
102
+ color var(--ylf-duration-fast, 160ms),
103
+ box-shadow var(--ylf-duration-fast, 160ms);
104
+ }
105
+
106
+ .ylf-segmented[data-size='sm'] .ylf-segmented__item {
107
+ padding-inline: 8px;
108
+ font-size: 12px;
109
+ }
110
+
111
+ .ylf-segmented[data-icon-only] .ylf-segmented__item {
112
+ width: calc(var(--ylf-segmented-item-size) + 4px);
113
+ padding: 0;
114
+ }
115
+
116
+ .ylf-segmented__item:hover:not([data-disabled], [aria-pressed='true']) {
117
+ color: var(--ylf-c-text, #0f172a);
118
+ background: color-mix(in srgb, var(--ylf-c-surface, #fff) 60%, transparent);
119
+ }
120
+
121
+ .ylf-segmented__item[aria-pressed='true'] {
122
+ color: var(--ylf-c-brand, #2563eb);
123
+ background: var(--ylf-c-surface, #fff);
124
+ border-color: var(--ylf-c-border, #e2e8f0);
125
+ box-shadow: var(--ylf-shadow-sm);
126
+ }
127
+
128
+ .ylf-segmented__item:focus-visible {
129
+ position: relative;
130
+ z-index: 1;
131
+ outline: 2px solid var(--ylf-c-brand, #2563eb);
132
+ outline-offset: 2px;
133
+ }
134
+
135
+ .ylf-segmented__item[data-disabled] {
136
+ cursor: not-allowed;
137
+ opacity: 0.45;
138
+ }
139
+
140
+ .ylf-segmented__icon {
141
+ display: inline-flex;
142
+ align-items: center;
143
+ justify-content: center;
144
+ flex: none;
145
+ width: 16px;
146
+ height: 16px;
147
+ font-size: 16px;
148
+ line-height: 1;
149
+ }
150
+
151
+ .ylf-segmented__icon > :where(svg, i, span) {
152
+ width: 100%;
153
+ height: 100%;
154
+ }
155
+
156
+ .ylf-segmented__count {
157
+ min-width: 16px;
158
+ color: var(--ylf-c-text-2, #475569);
159
+ font-size: 10px;
160
+ font-variant-numeric: tabular-nums;
161
+ text-align: center;
162
+ }
163
+
164
+ .ylf-segmented__item[aria-pressed='true'] .ylf-segmented__count {
165
+ color: inherit;
166
+ }
167
+
168
+ @media (pointer: coarse) {
169
+ .ylf-segmented .ylf-segmented__item {
170
+ min-width: 44px;
171
+ min-height: 44px;
172
+ }
173
+ }
174
+
175
+ @media (prefers-reduced-motion: reduce) {
176
+ .ylf-segmented__item {
177
+ transition: none;
178
+ }
179
+ }
180
+ </style>
@@ -6,12 +6,21 @@ defineOptions({ inheritAttrs: false })
6
6
 
7
7
  const props = withDefaults(defineProps<{
8
8
  /** color 可使用 CSS 色值或公共 token,例如 var(--ylf-accent-blue) */
9
- options: { label: string, value: T, disabled?: boolean, color?: string }[]
9
+ options: readonly { label: string, value: T, disabled?: boolean, color?: string }[]
10
+ /** Compact workbench or comfortable form density. */
11
+ size?: 'sm' | 'md'
10
12
  placeholder?: string
11
13
  disabled?: boolean
14
+ /** Native form field name and validation. */
15
+ name?: string
16
+ required?: boolean
17
+ /** Existing container in the same theme boundary. */
18
+ portalTo?: string | HTMLElement
12
19
  }>(), {
20
+ size: 'md',
13
21
  placeholder: '请选择',
14
22
  disabled: false,
23
+ required: false,
15
24
  })
16
25
 
17
26
  const model = defineModel<T>()
@@ -19,20 +28,22 @@ const selectedOption = computed(() => props.options.find(option => option.value
19
28
  </script>
20
29
 
21
30
  <template>
22
- <SelectRoot v-model="model" :disabled="disabled">
23
- <SelectTrigger class="ylf-select__trigger" :aria-label="$attrs.id || $attrs['aria-labelledby'] ? undefined : placeholder" v-bind="$attrs">
31
+ <SelectRoot v-model="model" :disabled="disabled" :name="name" :required="required">
32
+ <SelectTrigger class="ylf-select__trigger" :data-size="size" :aria-label="$attrs.id || $attrs['aria-labelledby'] ? undefined : placeholder" v-bind="$attrs">
24
33
  <SelectValue :placeholder="placeholder" class="ylf-select__label">
25
34
  <span v-if="selectedOption?.color" class="ylf-select__swatch" :style="{ backgroundColor: selectedOption.color }" aria-hidden="true" />
26
35
  <span>{{ selectedOption?.label ?? placeholder }}</span>
27
36
  </SelectValue>
28
37
  <SelectIcon class="ylf-select__icon">
29
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
30
- <path d="M6 9l6 6 6-6" />
31
- </svg>
38
+ <slot name="icon">
39
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
40
+ <path d="M6 9l6 6 6-6" />
41
+ </svg>
42
+ </slot>
32
43
  </SelectIcon>
33
44
  </SelectTrigger>
34
- <SelectPortal>
35
- <SelectContent class="ylf-select__content" position="popper" :side-offset="6">
45
+ <SelectPortal :to="portalTo">
46
+ <SelectContent class="ylf-select__content" :data-size="size" position="popper" :side-offset="6">
36
47
  <SelectViewport class="ylf-select__viewport">
37
48
  <SelectItem
38
49
  v-for="o in options"
@@ -46,9 +57,11 @@ const selectedOption = computed(() => props.options.find(option => option.value
46
57
  <span>{{ o.label }}</span>
47
58
  </SelectItemText>
48
59
  <SelectItemIndicator class="ylf-select__check">
49
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
50
- <path d="M5 12.5l4.5 4.5L19 7" />
51
- </svg>
60
+ <slot name="indicator">
61
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
62
+ <path d="M5 12.5l4.5 4.5L19 7" />
63
+ </svg>
64
+ </slot>
52
65
  </SelectItemIndicator>
53
66
  </SelectItem>
54
67
  </SelectViewport>
@@ -119,7 +132,7 @@ const selectedOption = computed(() => props.options.find(option => option.value
119
132
  display: inline-flex;
120
133
  color: var(--ylf-c-text-3, #64748b);
121
134
 
122
- svg {
135
+ :where(svg, i, span) {
123
136
  width: 18px;
124
137
  height: 18px;
125
138
  }
@@ -174,12 +187,33 @@ const selectedOption = computed(() => props.options.find(option => option.value
174
187
  display: inline-flex;
175
188
  color: var(--ylf-c-brand, #2563eb);
176
189
 
177
- svg {
190
+ :where(svg, i, span) {
178
191
  width: 15px;
179
192
  height: 15px;
180
193
  }
181
194
  }
182
195
 
196
+ .ylf-select__trigger[data-size='sm'] {
197
+ min-width: 0;
198
+ min-height: 34px;
199
+ padding: 5px 10px;
200
+ font-size: 12px;
201
+ border-radius: var(--ylf-radius-sm, 10px);
202
+ }
203
+
204
+ .ylf-select__content[data-size='sm'] .ylf-select__item {
205
+ min-height: 30px;
206
+ padding: 5px 8px;
207
+ font-size: 12px;
208
+ }
209
+
210
+ @media (pointer: coarse) {
211
+ .ylf-select__trigger[data-size='sm'],
212
+ .ylf-select__content[data-size='sm'] .ylf-select__item {
213
+ min-height: 44px;
214
+ }
215
+ }
216
+
183
217
  @keyframes ylf-pop-in {
184
218
  from {
185
219
  opacity: 0;
@@ -6,13 +6,19 @@ withDefaults(defineProps<{
6
6
  max?: number
7
7
  step?: number
8
8
  disabled?: boolean
9
+ /** Accessible name for a single value or the range as a whole. */
10
+ label?: string
11
+ /** Per-thumb accessible names, such as minimum and maximum price. */
12
+ thumbLabels?: readonly string[]
9
13
  }>(), {
10
14
  min: 0,
11
15
  max: 100,
12
16
  step: 1,
13
17
  disabled: false,
18
+ label: '数值',
14
19
  })
15
20
 
21
+ const emit = defineEmits<{ valueCommit: [value: number[]] }>()
16
22
  const model = defineModel<number[]>({ default: () => [50] })
17
23
  </script>
18
24
 
@@ -24,11 +30,17 @@ const model = defineModel<number[]>({ default: () => [50] })
24
30
  :step="step"
25
31
  :disabled="disabled"
26
32
  class="ylf-slider"
33
+ @value-commit="emit('valueCommit', $event)"
27
34
  >
28
35
  <SliderTrack class="ylf-slider__track">
29
36
  <SliderRange class="ylf-slider__range" />
30
37
  </SliderTrack>
31
- <SliderThumb v-for="(_, i) in model" :key="i" class="ylf-slider__thumb" />
38
+ <SliderThumb
39
+ v-for="(_, i) in model"
40
+ :key="i"
41
+ class="ylf-slider__thumb"
42
+ :aria-label="thumbLabels?.[i] || (model.length === 1 ? label : `${label} ${i + 1}`)"
43
+ />
32
44
  </SliderRoot>
33
45
  </template>
34
46
 
@@ -66,7 +78,7 @@ const model = defineModel<number[]>({ default: () => [50] })
66
78
  width: 18px;
67
79
  height: 18px;
68
80
  border-radius: 50%;
69
- background: #fff;
81
+ background: var(--ylf-c-surface, #fff);
70
82
  border: 2px solid var(--ylf-c-brand, #2563eb);
71
83
  box-shadow: var(--ylf-shadow-sm, 0 2px 8px -2px rgba(15, 23, 42, 0.12));
72
84
  cursor: grab;
@@ -86,4 +98,22 @@ const model = defineModel<number[]>({ default: () => [50] })
86
98
  }
87
99
  }
88
100
  }
101
+
102
+ @media (pointer: coarse) {
103
+ .ylf-slider {
104
+ height: 44px;
105
+ }
106
+
107
+ .ylf-slider__thumb::before {
108
+ position: absolute;
109
+ inset: -13px;
110
+ content: '';
111
+ }
112
+ }
113
+
114
+ @media (prefers-reduced-motion: reduce) {
115
+ .ylf-slider__thumb {
116
+ transition: none;
117
+ }
118
+ }
89
119
  </style>
@@ -8,7 +8,7 @@ interface TabItem {
8
8
  }
9
9
 
10
10
  withDefaults(defineProps<{
11
- items: TabItem[]
11
+ items: readonly TabItem[]
12
12
  orientation?: 'horizontal' | 'vertical'
13
13
  }>(), {
14
14
  orientation: 'horizontal',