@dnax/ui 0.0.1
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.
- package/README.md +15 -0
- package/components/QAccordion.vue +68 -0
- package/components/QAccordionContent.vue +62 -0
- package/components/QAccordionItem.vue +24 -0
- package/components/QAccordionTrigger.vue +51 -0
- package/components/QActionSheet.vue +159 -0
- package/components/QApp.vue +8 -0
- package/components/QAutocomplete.vue +316 -0
- package/components/QAvatar.vue +92 -0
- package/components/QBackHeader.vue +69 -0
- package/components/QBackTop.vue +125 -0
- package/components/QBadge.vue +55 -0
- package/components/QBottomSheet.vue +177 -0
- package/components/QBottomSheetFooter.vue +9 -0
- package/components/QBottomSheetHeader.vue +35 -0
- package/components/QBottomSheetProvider.vue +62 -0
- package/components/QBottomSheetTrigger.vue +18 -0
- package/components/QBtn.vue +144 -0
- package/components/QBtnGroup.vue +31 -0
- package/components/QBubble.vue +36 -0
- package/components/QBubbleContent.vue +28 -0
- package/components/QBubbleGroup.vue +25 -0
- package/components/QBubbleReactions.vue +40 -0
- package/components/QCard.vue +63 -0
- package/components/QCardActions.vue +27 -0
- package/components/QCardContent.vue +29 -0
- package/components/QCarousel.vue +115 -0
- package/components/QCarouselContent.vue +23 -0
- package/components/QCarouselItem.vue +20 -0
- package/components/QCarouselNav.vue +51 -0
- package/components/QCheckbox.vue +146 -0
- package/components/QChip.vue +91 -0
- package/components/QCircularProgress.vue +122 -0
- package/components/QCol.vue +52 -0
- package/components/QCollapse.vue +141 -0
- package/components/QConfigProvider.vue +164 -0
- package/components/QContainer.vue +201 -0
- package/components/QCountDown.vue +164 -0
- package/components/QCountryPicker.vue +593 -0
- package/components/QDataGrid.vue +491 -0
- package/components/QDatePicker.vue +250 -0
- package/components/QDialog.vue +163 -0
- package/components/QDialogFooter.vue +9 -0
- package/components/QDialogHeader.vue +41 -0
- package/components/QDialogProvider.vue +65 -0
- package/components/QDialogTrigger.vue +18 -0
- package/components/QFab.vue +80 -0
- package/components/QFabAction.vue +48 -0
- package/components/QFilePicker.vue +294 -0
- package/components/QFooter.vue +72 -0
- package/components/QGallery.vue +280 -0
- package/components/QGrid.vue +54 -0
- package/components/QHeader.vue +38 -0
- package/components/QIcon.vue +53 -0
- package/components/QImagePicker.vue +248 -0
- package/components/QImagePreview.vue +225 -0
- package/components/QImagePreviewProvider.vue +35 -0
- package/components/QImg.vue +142 -0
- package/components/QInfiniteScroll.vue +109 -0
- package/components/QInnerLoading.vue +67 -0
- package/components/QInput.vue +289 -0
- package/components/QInputOtp.vue +238 -0
- package/components/QInputPassword.vue +67 -0
- package/components/QInputTag.vue +281 -0
- package/components/QIntersection.vue +119 -0
- package/components/QItem.vue +60 -0
- package/components/QItemSection.vue +47 -0
- package/components/QLinearProgress.vue +94 -0
- package/components/QList.vue +39 -0
- package/components/QLoading.vue +77 -0
- package/components/QLoadingProvider.vue +41 -0
- package/components/QMarquee.vue +56 -0
- package/components/QMessageScroller.vue +10 -0
- package/components/QMessageScrollerButton.vue +48 -0
- package/components/QMessageScrollerContent.vue +31 -0
- package/components/QMessageScrollerItem.vue +49 -0
- package/components/QMessageScrollerProvider.vue +198 -0
- package/components/QMessageScrollerViewport.vue +109 -0
- package/components/QNavMenu.vue +75 -0
- package/components/QNavMenuContent.vue +10 -0
- package/components/QNavMenuItem.vue +60 -0
- package/components/QNavMenuTrigger.vue +96 -0
- package/components/QNotifyProvider.vue +33 -0
- package/components/QNotifyToast.vue +60 -0
- package/components/QPage.vue +56 -0
- package/components/QPagination.vue +195 -0
- package/components/QParallax.vue +124 -0
- package/components/QPullToRefresh.vue +138 -0
- package/components/QRadio.vue +99 -0
- package/components/QRating.vue +105 -0
- package/components/QReorder.vue +191 -0
- package/components/QRollingText.vue +225 -0
- package/components/QRow.vue +23 -0
- package/components/QSafeArea.vue +39 -0
- package/components/QScrollArea.vue +118 -0
- package/components/QSelect.vue +698 -0
- package/components/QSeparator.vue +39 -0
- package/components/QSidebar.vue +153 -0
- package/components/QSidebarContent.vue +14 -0
- package/components/QSidebarFooter.vue +14 -0
- package/components/QSidebarHeader.vue +14 -0
- package/components/QSidebarMenu.vue +9 -0
- package/components/QSidebarMenuButton.vue +59 -0
- package/components/QSidebarMenuItem.vue +9 -0
- package/components/QSidebarTrigger.vue +32 -0
- package/components/QSkeleton.vue +95 -0
- package/components/QSlider.vue +205 -0
- package/components/QSpace.vue +10 -0
- package/components/QSpinner.vue +172 -0
- package/components/QSplitter.vue +145 -0
- package/components/QSticky.vue +181 -0
- package/components/QSwipeCell.vue +217 -0
- package/components/QSwiper.vue +102 -0
- package/components/QSwiperSlide.vue +19 -0
- package/components/QSyntax.vue +171 -0
- package/components/QTab.vue +175 -0
- package/components/QTabPanel.vue +50 -0
- package/components/QTabPanels.vue +106 -0
- package/components/QTable.vue +575 -0
- package/components/QTabs.vue +188 -0
- package/components/QText.vue +316 -0
- package/components/QTextCaption.vue +122 -0
- package/components/QToolbar.vue +28 -0
- package/components/QTooltip.vue +313 -0
- package/components/QUploader.vue +473 -0
- package/components/QVideo.vue +289 -0
- package/components/QVirtualScroll.vue +190 -0
- package/components/internal/QDateCalendar.vue +153 -0
- package/index.ts +151 -0
- package/lib/breakpoints.ts +44 -0
- package/lib/colors.ts +24 -0
- package/lib/config.ts +34 -0
- package/lib/countries.ts +261 -0
- package/lib/formula.ts +630 -0
- package/lib/icons.ts +38 -0
- package/lib/loading.ts +75 -0
- package/lib/overlayBack.ts +124 -0
- package/lib/platform.ts +146 -0
- package/lib/primitive.ts +25 -0
- package/lib/q.ts +287 -0
- package/lib/screen.ts +105 -0
- package/lib/search.ts +35 -0
- package/lib/themeVars.ts +22 -0
- package/lib/useComponentProps.ts +54 -0
- package/lib/utils.ts +7 -0
- package/module.ts +89 -0
- package/package.json +41 -0
- package/scripts/generate-exports.ts +56 -0
- package/styles/main.css +7627 -0
- package/tsconfig.json +33 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// QImg — API Quasar : <q-img src="…" ratio="16/9" contain position="50% 50%" spinner-color loading />
|
|
3
|
+
// Slots : default (overlay), loading (spinner par défaut), error
|
|
4
|
+
// Placeholder : placeholder-src (image basse résolution affichée pendant le chargement)
|
|
5
|
+
import { computed, ref, watch } from "vue"
|
|
6
|
+
import { cn } from "../lib/utils"
|
|
7
|
+
import { colorValue } from "../lib/colors"
|
|
8
|
+
import { radiusStyle, useRadius } from "../lib/useComponentProps"
|
|
9
|
+
import type { RadiusProp } from "../lib/useComponentProps"
|
|
10
|
+
|
|
11
|
+
interface Props {
|
|
12
|
+
src?: string
|
|
13
|
+
alt?: string
|
|
14
|
+
/** Ratio d'affichage ("16/9", "4/3", "1", nombre) */
|
|
15
|
+
ratio?: string | number
|
|
16
|
+
/** object-fit: contain (sinon cover) */
|
|
17
|
+
contain?: boolean
|
|
18
|
+
/** Position de l'image ("50% 50%") */
|
|
19
|
+
position?: string
|
|
20
|
+
/** Force l'état chargement (spinner) */
|
|
21
|
+
loading?: boolean
|
|
22
|
+
spinnerColor?: string
|
|
23
|
+
spinnerSize?: string
|
|
24
|
+
/** Image placeholder (basse résolution) affichée pendant le chargement */
|
|
25
|
+
placeholderSrc?: string
|
|
26
|
+
/** Coins arrondis : true = pilule, ou échelle xs|sm|md|lg (none = carré) */
|
|
27
|
+
radius?: RadiusProp
|
|
28
|
+
imgClass?: string
|
|
29
|
+
imgStyle?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
33
|
+
position: "50% 50%",
|
|
34
|
+
loading: false,
|
|
35
|
+
spinnerColor: "",
|
|
36
|
+
spinnerSize: "",
|
|
37
|
+
placeholderSrc: "",
|
|
38
|
+
imgClass: "",
|
|
39
|
+
imgStyle: "",
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
const emit = defineEmits<{ load: [event: Event]; error: [event: Event] }>()
|
|
43
|
+
|
|
44
|
+
const imgLoading = ref(false)
|
|
45
|
+
const imgError = ref(false)
|
|
46
|
+
|
|
47
|
+
// Dès que src change : retour en état chargement (le @load remettra à false)
|
|
48
|
+
watch(
|
|
49
|
+
() => props.src,
|
|
50
|
+
(s) => {
|
|
51
|
+
imgLoading.value = !!s
|
|
52
|
+
imgError.value = false
|
|
53
|
+
},
|
|
54
|
+
{ immediate: true },
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
const containerStyle = computed<Record<string, string>>(() => {
|
|
58
|
+
const style: Record<string, string> = {}
|
|
59
|
+
if (props.ratio !== undefined) {
|
|
60
|
+
style.aspectRatio = String(props.ratio).replace("/", " / ")
|
|
61
|
+
}
|
|
62
|
+
return style
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
const spinnerStyle = computed<Record<string, string>>(() => {
|
|
66
|
+
const style: Record<string, string> = {}
|
|
67
|
+
if (props.spinnerColor) style.color = colorValue(props.spinnerColor)
|
|
68
|
+
if (props.spinnerSize) {
|
|
69
|
+
style.width = props.spinnerSize
|
|
70
|
+
style.height = props.spinnerSize
|
|
71
|
+
}
|
|
72
|
+
return style
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
const imageClasses = computed(() =>
|
|
76
|
+
cn("q-img__image", props.contain && "q-img__image--contain", props.imgClass),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
// radius : prop explicite > composantProps.QImg > composantProps.default → --q-radius
|
|
80
|
+
const effectiveRadius = useRadius("QImg", () => props.radius)
|
|
81
|
+
const roundedStyle = computed(() => radiusStyle(effectiveRadius.value))
|
|
82
|
+
|
|
83
|
+
const imageStyle = computed(() => `${props.imgStyle}; object-position: ${props.position}`)
|
|
84
|
+
|
|
85
|
+
// L'image est prête (chargée) → fondu
|
|
86
|
+
const ready = computed(() => !imgLoading.value && !imgError.value)
|
|
87
|
+
|
|
88
|
+
const onLoad = (e: Event) => {
|
|
89
|
+
imgLoading.value = false
|
|
90
|
+
imgError.value = false
|
|
91
|
+
emit("load", e)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const onError = (e: Event) => {
|
|
95
|
+
imgLoading.value = false
|
|
96
|
+
imgError.value = true
|
|
97
|
+
emit("error", e)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const showLoading = computed(() => props.loading || imgLoading.value)
|
|
101
|
+
</script>
|
|
102
|
+
|
|
103
|
+
<template>
|
|
104
|
+
<div class="q-img" :style="[containerStyle, roundedStyle]">
|
|
105
|
+
<!-- Placeholder basse résolution pendant le chargement -->
|
|
106
|
+
<img
|
|
107
|
+
v-if="placeholderSrc && imgLoading && !imgError"
|
|
108
|
+
:src="placeholderSrc"
|
|
109
|
+
alt=""
|
|
110
|
+
aria-hidden="true"
|
|
111
|
+
class="q-img__placeholder"
|
|
112
|
+
:class="props.contain && 'q-img__image--contain'"
|
|
113
|
+
/>
|
|
114
|
+
|
|
115
|
+
<img
|
|
116
|
+
v-if="src"
|
|
117
|
+
:src="src"
|
|
118
|
+
:alt="alt"
|
|
119
|
+
class="q-img__image"
|
|
120
|
+
:class="[imageClasses, ready && 'q-img__image--ready']"
|
|
121
|
+
:style="imageStyle"
|
|
122
|
+
loading="lazy"
|
|
123
|
+
@load="onLoad"
|
|
124
|
+
@error="onError"
|
|
125
|
+
/>
|
|
126
|
+
|
|
127
|
+
<!-- Loading / erreur -->
|
|
128
|
+
<div v-if="showLoading" class="q-img__state">
|
|
129
|
+
<slot name="loading">
|
|
130
|
+
<span class="q-spinner-ring" :style="spinnerStyle" aria-hidden="true" />
|
|
131
|
+
</slot>
|
|
132
|
+
</div>
|
|
133
|
+
<div v-else-if="imgError" class="q-img__state">
|
|
134
|
+
<slot name="error" />
|
|
135
|
+
</div>
|
|
136
|
+
|
|
137
|
+
<!-- Overlay -->
|
|
138
|
+
<div class="q-img__overlay">
|
|
139
|
+
<slot />
|
|
140
|
+
</div>
|
|
141
|
+
</div>
|
|
142
|
+
</template>
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// QInfiniteScroll — API Quasar : <q-infinite-scroll :offset="250" @load="onLoad" animated>
|
|
3
|
+
// Charge plus de contenu quand on approche du bas (scroll fenêtre ou conteneur scrollable).
|
|
4
|
+
// Le handler reçoit (index, done) — appeler done() quand le chargement est fini.
|
|
5
|
+
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from "vue"
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
/** Distance (px) du bas avant de déclencher le chargement (défaut : 250) */
|
|
9
|
+
offset?: number
|
|
10
|
+
/** Désactive le chargement */
|
|
11
|
+
disable?: boolean
|
|
12
|
+
/** Anime l'arrivée des nouveaux items (fondu + glissement vers le haut) */
|
|
13
|
+
animated?: boolean
|
|
14
|
+
/** Masque l'indicateur de chargement (scroll silencieux — TikTok/Instagram) */
|
|
15
|
+
hideLoading?: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
19
|
+
offset: 250,
|
|
20
|
+
disable: false,
|
|
21
|
+
animated: false,
|
|
22
|
+
hideLoading: false,
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
const emit = defineEmits<{
|
|
26
|
+
load: [index: number, done: () => void]
|
|
27
|
+
}>()
|
|
28
|
+
|
|
29
|
+
const rootEl = ref<HTMLElement | null>(null)
|
|
30
|
+
const loading = ref(false)
|
|
31
|
+
let loadIndex = 0
|
|
32
|
+
|
|
33
|
+
// Conteneur scrollable le plus proche (ou la fenêtre)
|
|
34
|
+
const scrollContainer = ref<HTMLElement | Window | null>(null)
|
|
35
|
+
|
|
36
|
+
const findScrollContainer = (el: HTMLElement | null): HTMLElement | Window | null => {
|
|
37
|
+
let node: HTMLElement | null = el?.parentElement ?? null
|
|
38
|
+
while (node) {
|
|
39
|
+
if (/(auto|scroll)/.test(getComputedStyle(node).overflowY)) return node
|
|
40
|
+
node = node.parentElement
|
|
41
|
+
}
|
|
42
|
+
return typeof window !== "undefined" ? window : null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const load = () => {
|
|
46
|
+
if (props.disable || loading.value) return
|
|
47
|
+
loading.value = true
|
|
48
|
+
loadIndex += 1
|
|
49
|
+
emit("load", loadIndex, () => {
|
|
50
|
+
loading.value = false
|
|
51
|
+
nextTick(check)
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const check = () => {
|
|
56
|
+
if (props.disable || loading.value) return
|
|
57
|
+
const el = rootEl.value
|
|
58
|
+
const sc = scrollContainer.value
|
|
59
|
+
if (!el || !sc) return
|
|
60
|
+
const rect = el.getBoundingClientRect()
|
|
61
|
+
if (sc === window) {
|
|
62
|
+
if (rect.bottom <= window.innerHeight + props.offset) load()
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
const scRect = (sc as HTMLElement).getBoundingClientRect()
|
|
66
|
+
if (rect.bottom <= scRect.bottom + props.offset) load()
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let resizeObserver: ResizeObserver | undefined
|
|
71
|
+
|
|
72
|
+
onMounted(() => {
|
|
73
|
+
scrollContainer.value = findScrollContainer(rootEl.value)
|
|
74
|
+
if (typeof window !== "undefined") {
|
|
75
|
+
// capture : capte aussi le scroll des conteneurs internes
|
|
76
|
+
window.addEventListener("scroll", check, true)
|
|
77
|
+
window.addEventListener("resize", check)
|
|
78
|
+
if (typeof ResizeObserver !== "undefined") {
|
|
79
|
+
resizeObserver = new ResizeObserver(check)
|
|
80
|
+
if (rootEl.value) resizeObserver.observe(rootEl.value)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
check()
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
onBeforeUnmount(() => {
|
|
87
|
+
if (typeof window !== "undefined") {
|
|
88
|
+
window.removeEventListener("scroll", check, true)
|
|
89
|
+
window.removeEventListener("resize", check)
|
|
90
|
+
resizeObserver?.disconnect()
|
|
91
|
+
}
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
const classes = computed(() => ({
|
|
95
|
+
"q-infinite-scroll--animated": props.animated,
|
|
96
|
+
}))
|
|
97
|
+
</script>
|
|
98
|
+
|
|
99
|
+
<template>
|
|
100
|
+
<div ref="rootEl" class="q-infinite-scroll" :class="classes">
|
|
101
|
+
<slot />
|
|
102
|
+
<div v-if="loading && !hideLoading" class="q-infinite-scroll__loading" role="status" aria-live="polite">
|
|
103
|
+
<slot name="loading">
|
|
104
|
+
<span class="q-spinner-ring" aria-hidden="true" />
|
|
105
|
+
<span class="q-infinite-scroll__loading-label">Loading more…</span>
|
|
106
|
+
</slot>
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
</template>
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// QInnerLoading — overlay de chargement dans un conteneur : <q-inner-loading showing label="…" color="primary" />
|
|
3
|
+
// Le parent doit être position: relative. Rendu en fondu (Transition).
|
|
4
|
+
import { computed } from "vue"
|
|
5
|
+
import { Icon } from "@iconify/vue"
|
|
6
|
+
import { cn } from "../lib/utils"
|
|
7
|
+
import { colorValue } from "../lib/colors"
|
|
8
|
+
|
|
9
|
+
const SIZE_MAP: Record<string, string> = { sm: "20px", md: "32px", lg: "48px" }
|
|
10
|
+
|
|
11
|
+
interface Props {
|
|
12
|
+
/** Affiche/masque le loading */
|
|
13
|
+
showing?: boolean
|
|
14
|
+
/** Libellé sous le spinner */
|
|
15
|
+
label?: string
|
|
16
|
+
/** Taille : sm | md | lg ou valeur CSS */
|
|
17
|
+
size?: string
|
|
18
|
+
/** Couleur du spinner (token ou hex) */
|
|
19
|
+
color?: string
|
|
20
|
+
/** Icône Iconify en rotation (sinon spinner CSS) */
|
|
21
|
+
icon?: string
|
|
22
|
+
/** Fond sombre */
|
|
23
|
+
dark?: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
27
|
+
showing: false,
|
|
28
|
+
size: "md",
|
|
29
|
+
color: "primary",
|
|
30
|
+
icon: "",
|
|
31
|
+
dark: false,
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
const spinnerStyle = computed<Record<string, string>>(() => {
|
|
35
|
+
const style: Record<string, string> = {}
|
|
36
|
+
style.width = SIZE_MAP[props.size] ?? props.size
|
|
37
|
+
style.height = SIZE_MAP[props.size] ?? props.size
|
|
38
|
+
style.color = colorValue(props.color)
|
|
39
|
+
return style
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
const overlayClasses = computed(() => cn("q-inner-loading", props.dark && "q-inner-loading--dark"))
|
|
43
|
+
</script>
|
|
44
|
+
|
|
45
|
+
<template>
|
|
46
|
+
<Transition name="q-inner-loading">
|
|
47
|
+
<div
|
|
48
|
+
v-if="showing"
|
|
49
|
+
class="q-inner-loading"
|
|
50
|
+
:class="overlayClasses"
|
|
51
|
+
role="status"
|
|
52
|
+
aria-live="polite"
|
|
53
|
+
>
|
|
54
|
+
<slot>
|
|
55
|
+
<Icon
|
|
56
|
+
v-if="icon"
|
|
57
|
+
:icon="icon"
|
|
58
|
+
class="q-inner-loading__icon"
|
|
59
|
+
:style="spinnerStyle"
|
|
60
|
+
aria-hidden="true"
|
|
61
|
+
/>
|
|
62
|
+
<span v-else class="q-spinner-ring" :style="spinnerStyle" aria-hidden="true" />
|
|
63
|
+
<span v-if="label" class="q-inner-loading__label">{{ label }}</span>
|
|
64
|
+
</slot>
|
|
65
|
+
</div>
|
|
66
|
+
</Transition>
|
|
67
|
+
</template>
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// QInput — API Quasar : <q-input v-model="…" label="Email" outlined dense clearable counter maxlength="60" hint error error-message />
|
|
3
|
+
// Variants : outlined (défaut) | filled | borderless ; le label est toujours affiché au-dessus du champ.
|
|
4
|
+
import { computed, nextTick, ref, watch } from "vue"
|
|
5
|
+
import { Icon } from "@iconify/vue"
|
|
6
|
+
import { icons } from "../lib/icons"
|
|
7
|
+
import { cn } from "../lib/utils"
|
|
8
|
+
import { radiusStyle, useRadius } from "../lib/useComponentProps"
|
|
9
|
+
import type { RadiusProp } from "../lib/useComponentProps"
|
|
10
|
+
|
|
11
|
+
interface Props {
|
|
12
|
+
modelValue?: string | number | null
|
|
13
|
+
/** Type de champ (text par défaut) */
|
|
14
|
+
type?: "text" | "password" | "number" | "email" | "search" | "tel" | "url"
|
|
15
|
+
/** Label affiché au-dessus du champ */
|
|
16
|
+
label?: string
|
|
17
|
+
/** @deprecated Le label est toujours affiché au-dessus — cette prop est ignorée */
|
|
18
|
+
stackLabel?: boolean
|
|
19
|
+
/** Aide affichée sous le champ */
|
|
20
|
+
hint?: string
|
|
21
|
+
/** État d'erreur */
|
|
22
|
+
error?: boolean
|
|
23
|
+
/** Message d'erreur (affiché quand error) */
|
|
24
|
+
errorMessage?: string
|
|
25
|
+
/** Affiche le compteur (nécessite maxlength) */
|
|
26
|
+
counter?: boolean
|
|
27
|
+
maxlength?: number
|
|
28
|
+
/** Préfixe dans le champ */
|
|
29
|
+
prefix?: string
|
|
30
|
+
/** Suffixe dans le champ */
|
|
31
|
+
suffix?: string
|
|
32
|
+
/** Icône Iconify à gauche (remplacée par le slot #prepend s'il est fourni) */
|
|
33
|
+
iconLeft?: string
|
|
34
|
+
/** Icône Iconify à droite (remplacée par le slot #append s'il est fourni) */
|
|
35
|
+
iconRight?: string
|
|
36
|
+
/** Masque de saisie : # = chiffre, A = lettre, N = alphanumérique, X = quelconque (ex. "##-##-##") */
|
|
37
|
+
mask?: string
|
|
38
|
+
/** Remplit les emplacements vides par "_" (défaut : la saisie s'arrête au dernier caractère) */
|
|
39
|
+
fillMask?: boolean
|
|
40
|
+
/** Émet la valeur sans les caractères du masque (littéraux et _) */
|
|
41
|
+
unmaskedValue?: boolean
|
|
42
|
+
/** Bouton d'effacement */
|
|
43
|
+
clearable?: boolean
|
|
44
|
+
/** Bordure visible */
|
|
45
|
+
outlined?: boolean
|
|
46
|
+
/** Fond gris, soulignement */
|
|
47
|
+
filled?: boolean
|
|
48
|
+
/** Aucune bordure */
|
|
49
|
+
borderless?: boolean
|
|
50
|
+
/** Coins arrondis : true = pilule, ou échelle xs|sm|md|lg (none = carré) */
|
|
51
|
+
radius?: RadiusProp
|
|
52
|
+
/** Hauteur réduite */
|
|
53
|
+
dense?: boolean
|
|
54
|
+
/** Textarea à hauteur auto */
|
|
55
|
+
autogrow?: boolean
|
|
56
|
+
disable?: boolean
|
|
57
|
+
readonly?: boolean
|
|
58
|
+
placeholder?: string
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
62
|
+
type: "text",
|
|
63
|
+
stackLabel: false,
|
|
64
|
+
error: false,
|
|
65
|
+
counter: false,
|
|
66
|
+
clearable: false,
|
|
67
|
+
outlined: false,
|
|
68
|
+
filled: false,
|
|
69
|
+
borderless: false,
|
|
70
|
+
dense: false,
|
|
71
|
+
autogrow: false,
|
|
72
|
+
disable: false,
|
|
73
|
+
readonly: false,
|
|
74
|
+
fillMask: false,
|
|
75
|
+
unmaskedValue: false,
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
const emit = defineEmits<{
|
|
79
|
+
"update:modelValue": [value: string]
|
|
80
|
+
focus: [event: FocusEvent]
|
|
81
|
+
blur: [event: FocusEvent]
|
|
82
|
+
clear: []
|
|
83
|
+
keyup: [event: KeyboardEvent]
|
|
84
|
+
keydown: [event: KeyboardEvent]
|
|
85
|
+
}>()
|
|
86
|
+
|
|
87
|
+
const focused = ref(false)
|
|
88
|
+
const nativeEl = ref<HTMLInputElement | HTMLTextAreaElement | null>(null)
|
|
89
|
+
|
|
90
|
+
// radius : prop explicite > composantProps.QInput.radius ; échelle → --q-radius
|
|
91
|
+
const effectiveRadius = useRadius("QInput", () => props.radius)
|
|
92
|
+
const roundedStyle = computed(() => radiusStyle(effectiveRadius.value))
|
|
93
|
+
|
|
94
|
+
const value = computed(() => props.modelValue ?? "")
|
|
95
|
+
|
|
96
|
+
// ═════════ Masque de saisie : # chiffre, A lettre, N alphanumérique, X quelconque ═════════
|
|
97
|
+
const isToken = (c: string): boolean => c === "#" || c === "A" || c === "N" || c === "X"
|
|
98
|
+
|
|
99
|
+
const matchesToken = (tok: string, c: string): boolean =>
|
|
100
|
+
tok === "#"
|
|
101
|
+
? /[0-9]/.test(c)
|
|
102
|
+
: tok === "A"
|
|
103
|
+
? /[a-zA-Z]/.test(c)
|
|
104
|
+
: tok === "N"
|
|
105
|
+
? /[0-9a-zA-Z]/.test(c)
|
|
106
|
+
: true
|
|
107
|
+
|
|
108
|
+
/** Extrait les caractères réels (emplacements tokens) d'une valeur affichée masquée. */
|
|
109
|
+
const unmask = (v: string, fill: boolean): string => {
|
|
110
|
+
if (!props.mask) return v
|
|
111
|
+
let out = ""
|
|
112
|
+
let vi = 0
|
|
113
|
+
for (const ch of props.mask) {
|
|
114
|
+
if (!isToken(ch)) {
|
|
115
|
+
if (v[vi] === ch) vi++ // littéral présent → on le consomme
|
|
116
|
+
continue // sinon : ne pas consommer, c'est un vrai caractère
|
|
117
|
+
}
|
|
118
|
+
if (vi >= v.length) break
|
|
119
|
+
const c = v[vi]!
|
|
120
|
+
vi++
|
|
121
|
+
if (fill && c === "_") continue // emplacement vide
|
|
122
|
+
out += c
|
|
123
|
+
}
|
|
124
|
+
return out
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Applique le masque à une chaîne de caractères bruts (littéraux insérés, caractères invalides sautés). */
|
|
128
|
+
const mask = (raw: string, fill: boolean): string => {
|
|
129
|
+
if (!props.mask) return raw
|
|
130
|
+
let out = ""
|
|
131
|
+
let ri = 0
|
|
132
|
+
for (const ch of props.mask) {
|
|
133
|
+
// Plus de caractères bruts (sans fill) → on s'arrête (pas de littéraux de fin)
|
|
134
|
+
if (ri >= raw.length && !fill) break
|
|
135
|
+
if (!isToken(ch)) {
|
|
136
|
+
out += ch
|
|
137
|
+
continue
|
|
138
|
+
}
|
|
139
|
+
while (ri < raw.length && !matchesToken(ch, raw[ri]!)) ri++
|
|
140
|
+
if (ri >= raw.length) {
|
|
141
|
+
if (fill) out += "_"
|
|
142
|
+
else break
|
|
143
|
+
continue
|
|
144
|
+
}
|
|
145
|
+
out += raw[ri]!
|
|
146
|
+
ri++
|
|
147
|
+
}
|
|
148
|
+
return out
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Valeur affichée dans le champ : applique le masque au modelValue (brut si unmaskedValue). */
|
|
152
|
+
const displayValue = computed(() => {
|
|
153
|
+
const v = String(props.modelValue ?? "")
|
|
154
|
+
if (!props.mask) return v
|
|
155
|
+
const raw = props.unmaskedValue ? v : unmask(v, props.fillMask)
|
|
156
|
+
return mask(raw, props.fillMask)
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
const floatActive = computed(() => focused.value || value.value !== "" || props.stackLabel)
|
|
160
|
+
|
|
161
|
+
const fieldClasses = computed(() =>
|
|
162
|
+
cn(
|
|
163
|
+
"q-input",
|
|
164
|
+
props.outlined && "q-field--outlined",
|
|
165
|
+
props.filled && "q-field--filled",
|
|
166
|
+
props.borderless && "q-field--borderless",
|
|
167
|
+
effectiveRadius.value === true && "q-field--rounded",
|
|
168
|
+
props.dense && "q-field--dense",
|
|
169
|
+
props.error && "q-field--error",
|
|
170
|
+
props.disable && "q-field--disabled",
|
|
171
|
+
floatActive.value && "q-field--float",
|
|
172
|
+
),
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
const resizeTextarea = async () => {
|
|
176
|
+
const el = nativeEl.value
|
|
177
|
+
if (el && props.autogrow) {
|
|
178
|
+
await nextTick()
|
|
179
|
+
el.style.height = "auto"
|
|
180
|
+
el.style.height = `${el.scrollHeight}px`
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
watch(value, resizeTextarea)
|
|
185
|
+
|
|
186
|
+
const onInput = (e: Event) => {
|
|
187
|
+
const el = e.target as HTMLInputElement | HTMLTextAreaElement
|
|
188
|
+
const raw = el.value
|
|
189
|
+
if (!props.mask) {
|
|
190
|
+
emit("update:modelValue", raw)
|
|
191
|
+
return
|
|
192
|
+
}
|
|
193
|
+
const masked = mask(unmask(raw, props.fillMask), props.fillMask)
|
|
194
|
+
emit("update:modelValue", props.unmaskedValue ? unmask(masked, props.fillMask) : masked)
|
|
195
|
+
// Replique le texte masqué dans le champ et remet le caret à la fin
|
|
196
|
+
el.value = masked
|
|
197
|
+
const len = masked.length
|
|
198
|
+
el.setSelectionRange?.(len, len)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const onFocus = (e: FocusEvent) => {
|
|
202
|
+
focused.value = true
|
|
203
|
+
emit("focus", e)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const onBlur = (e: FocusEvent) => {
|
|
207
|
+
focused.value = false
|
|
208
|
+
emit("blur", e)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const onClear = () => {
|
|
212
|
+
emit("update:modelValue", "")
|
|
213
|
+
emit("clear")
|
|
214
|
+
nativeEl.value?.focus()
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const showClear = computed(
|
|
218
|
+
() => props.clearable && !props.disable && !props.readonly && value.value !== "",
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
const inputAttrs = computed(() => ({
|
|
222
|
+
type: props.type,
|
|
223
|
+
maxlength: props.maxlength,
|
|
224
|
+
placeholder: props.placeholder,
|
|
225
|
+
disabled: props.disable || undefined,
|
|
226
|
+
readonly: props.readonly || undefined,
|
|
227
|
+
}))
|
|
228
|
+
</script>
|
|
229
|
+
|
|
230
|
+
<template>
|
|
231
|
+
<div class="q-input" :class="fieldClasses" :style="roundedStyle">
|
|
232
|
+
<label v-if="label" class="q-field__label-stack">{{ label }}</label>
|
|
233
|
+
<div class="q-field__control">
|
|
234
|
+
<slot name="prepend">
|
|
235
|
+
<Icon v-if="iconLeft" :icon="iconLeft" class="q-field__icon" aria-hidden="true" />
|
|
236
|
+
</slot>
|
|
237
|
+
<span v-if="prefix" class="q-field__prefix">{{ prefix }}</span>
|
|
238
|
+
<textarea
|
|
239
|
+
v-if="autogrow"
|
|
240
|
+
ref="nativeEl"
|
|
241
|
+
class="q-field__native"
|
|
242
|
+
:value="displayValue"
|
|
243
|
+
rows="1"
|
|
244
|
+
v-bind="inputAttrs"
|
|
245
|
+
@input="onInput"
|
|
246
|
+
@focus="onFocus"
|
|
247
|
+
@blur="onBlur"
|
|
248
|
+
@keyup="(e) => emit('keyup', e)"
|
|
249
|
+
@keydown="(e) => emit('keydown', e)"
|
|
250
|
+
/>
|
|
251
|
+
<input
|
|
252
|
+
v-else
|
|
253
|
+
ref="nativeEl"
|
|
254
|
+
class="q-field__native"
|
|
255
|
+
:value="displayValue"
|
|
256
|
+
v-bind="inputAttrs"
|
|
257
|
+
@input="onInput"
|
|
258
|
+
@focus="onFocus"
|
|
259
|
+
@blur="onBlur"
|
|
260
|
+
@keyup="(e) => emit('keyup', e)"
|
|
261
|
+
@keydown="(e) => emit('keydown', e)"
|
|
262
|
+
/>
|
|
263
|
+
<span v-if="suffix" class="q-field__suffix">{{ suffix }}</span>
|
|
264
|
+
<button
|
|
265
|
+
v-if="showClear"
|
|
266
|
+
class="q-field__clear"
|
|
267
|
+
type="button"
|
|
268
|
+
aria-label="Effacer"
|
|
269
|
+
@click="onClear"
|
|
270
|
+
>
|
|
271
|
+
<Icon :icon="icons.x" aria-hidden="true" />
|
|
272
|
+
</button>
|
|
273
|
+
<slot name="append">
|
|
274
|
+
<Icon v-if="iconRight" :icon="iconRight" class="q-field__icon" aria-hidden="true" />
|
|
275
|
+
</slot>
|
|
276
|
+
</div>
|
|
277
|
+
<div class="q-field__bottom">
|
|
278
|
+
<div v-if="error" class="q-field__error">
|
|
279
|
+
<slot name="error">{{ errorMessage }}</slot>
|
|
280
|
+
</div>
|
|
281
|
+
<div v-else-if="hint || $slots.hint" class="q-field__hint">
|
|
282
|
+
<slot name="hint">{{ hint }}</slot>
|
|
283
|
+
</div>
|
|
284
|
+
<div v-if="counter && maxlength" class="q-field__counter">
|
|
285
|
+
{{ String(value).length }}/{{ maxlength }}
|
|
286
|
+
</div>
|
|
287
|
+
</div>
|
|
288
|
+
</div>
|
|
289
|
+
</template>
|