@alikhalilll/ui 1.2.2 → 1.2.4

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 (47) hide show
  1. package/dist/entries/drawer/components/ADrawer.vue.d.ts +1 -5
  2. package/dist/entries/drawer/components/ADrawerContent.vue.d.ts +1 -5
  3. package/dist/entries/drawer/components/ADrawerTrigger.vue.d.ts +1 -5
  4. package/dist/entries/input/components/AInput.vue.d.ts +1 -5
  5. package/dist/entries/popover/components/APopover.vue.d.ts +1 -5
  6. package/dist/entries/popover/components/APopoverContent.vue.d.ts +1 -5
  7. package/dist/entries/popover/components/APopoverTrigger.vue.d.ts +1 -5
  8. package/dist/entries/responsive-popover/components/AResponsivePopover.vue.d.ts +1 -5
  9. package/dist/entries/responsive-popover/components/AResponsivePopoverContent.vue.d.ts +1 -5
  10. package/dist/entries/responsive-popover/components/AResponsivePopoverTrigger.vue.d.ts +1 -5
  11. package/dist/entries/tell-input/components/ACountryFlag.vue.d.ts +1 -5
  12. package/dist/entries/tell-input/components/ACountrySelect.vue.d.ts +1 -5
  13. package/dist/entries/tell-input/components/ATellInput.vue.d.ts +1 -5
  14. package/entries/drawer/components/ADrawer.vue +16 -0
  15. package/entries/drawer/components/ADrawerContent.vue +35 -0
  16. package/entries/drawer/components/ADrawerOverlay.vue +25 -0
  17. package/entries/drawer/components/ADrawerTrigger.vue +13 -0
  18. package/entries/drawer/index.ts +4 -0
  19. package/entries/input/components/AInput.vue +111 -0
  20. package/entries/input/index.ts +1 -0
  21. package/entries/popover/components/APopover.vue +19 -0
  22. package/entries/popover/components/APopoverContent.vue +65 -0
  23. package/entries/popover/components/APopoverOverlay.vue +69 -0
  24. package/entries/popover/components/APopoverTrigger.vue +13 -0
  25. package/entries/popover/composables/useEventScrollLock.ts +193 -0
  26. package/entries/popover/index.ts +8 -0
  27. package/entries/responsive-popover/components/AResponsivePopover.vue +67 -0
  28. package/entries/responsive-popover/components/AResponsivePopoverContent.vue +80 -0
  29. package/entries/responsive-popover/components/AResponsivePopoverTrigger.vue +23 -0
  30. package/entries/responsive-popover/composables/useResponsivePopoverContext.ts +20 -0
  31. package/entries/responsive-popover/index.ts +3 -0
  32. package/entries/tell-input/components/ACountryFlag.vue +68 -0
  33. package/entries/tell-input/components/ACountrySelect.vue +522 -0
  34. package/entries/tell-input/components/ATellInput.vue +616 -0
  35. package/entries/tell-input/composables/useCountryDetection.ts +247 -0
  36. package/entries/tell-input/composables/useCountryMatching.ts +213 -0
  37. package/entries/tell-input/composables/usePhoneValidation.ts +573 -0
  38. package/entries/tell-input/composables/useTellInputValidation.ts +136 -0
  39. package/entries/tell-input/composables/useTypingPhase.ts +88 -0
  40. package/entries/tell-input/index.ts +29 -0
  41. package/entries/tell-input/utils/digits.ts +42 -0
  42. package/entries/tell-input/utils/flag-url.ts +10 -0
  43. package/entries/tell-input/utils/types.ts +169 -0
  44. package/package.json +4 -1
  45. package/utils/cn.ts +6 -0
  46. package/utils/index.ts +10 -0
  47. package/utils/sizes.ts +48 -0
@@ -0,0 +1,88 @@
1
+ import { ref, readonly, type ComputedRef, type Ref } from 'vue';
2
+ import { useDebounceFn } from '@vueuse/core';
3
+
4
+ /**
5
+ * Typing-phase state machine for the tel input.
6
+ *
7
+ * Owns the three reactive flags that drive the "is the user still typing?" UX:
8
+ *
9
+ * - `isDetecting` — true while the debounce window is in flight (user is mid-burst or
10
+ * has just paused). Drives the loading spinner in the picker slot.
11
+ * - `hasFinishedTyping` — false from the moment a key lands until the debounce settles.
12
+ * Gates validation visibility, so error/success states only appear once the user pauses.
13
+ * - `detectionAttempted` — flips true the first time the debounce fires on non-empty
14
+ * input. Used by the consumer to keep the country picker visible after a failed
15
+ * detection (so the user can pick manually instead of being stranded).
16
+ *
17
+ * Design notes:
18
+ *
19
+ * - The composable is pure state — it does not know about country detection, phone
20
+ * numbers, or libphonenumber. The consumer wires the `onSettle` callback to whatever
21
+ * "what to do when typing pauses" logic is appropriate (typically: try to detect a
22
+ * country from the current digits, mark a detection attempt, and apply the match).
23
+ *
24
+ * - `markDetectionAttempt()` is exposed separately so the caller controls *when* the
25
+ * "keep the picker visible" flag flips — not every settle triggers a real attempt
26
+ * (e.g. when input is empty or the user already picked a country manually).
27
+ *
28
+ * - Refs are exposed `readonly` so external code can't bypass the state machine; all
29
+ * transitions go through the exposed actions.
30
+ */
31
+ export interface UseTypingPhaseOptions {
32
+ /** Debounce window in ms. Reactive so consumers can change `detectDebounceMs` at runtime. */
33
+ debounceMs: ComputedRef<number>;
34
+ /** Fired when the debounce timer settles. Runs regardless of input state — use this
35
+ * to clear loading UI, then perform any pause-triggered work (e.g. detection). */
36
+ onSettle?: () => void;
37
+ }
38
+
39
+ export interface UseTypingPhaseReturn {
40
+ isDetecting: Readonly<Ref<boolean>>;
41
+ hasFinishedTyping: Readonly<Ref<boolean>>;
42
+ detectionAttempted: Readonly<Ref<boolean>>;
43
+ /** Call from the input handler on every keystroke that produces non-empty input. */
44
+ markTyping: () => void;
45
+ /** Flip `detectionAttempted` to true. Call from within the `onSettle` callback when
46
+ * a real detection attempt is about to run — so the picker stays visible after even
47
+ * a failed match. */
48
+ markDetectionAttempt: () => void;
49
+ /** Reset all three flags to defaults. Call when the input is cleared. */
50
+ reset: () => void;
51
+ }
52
+
53
+ export function useTypingPhase(opts: UseTypingPhaseOptions): UseTypingPhaseReturn {
54
+ const isDetecting = ref(false);
55
+ const hasFinishedTyping = ref(true);
56
+ const detectionAttempted = ref(false);
57
+
58
+ const settle = useDebounceFn(() => {
59
+ isDetecting.value = false;
60
+ hasFinishedTyping.value = true;
61
+ opts.onSettle?.();
62
+ }, opts.debounceMs);
63
+
64
+ function markTyping() {
65
+ isDetecting.value = true;
66
+ hasFinishedTyping.value = false;
67
+ settle();
68
+ }
69
+
70
+ function markDetectionAttempt() {
71
+ detectionAttempted.value = true;
72
+ }
73
+
74
+ function reset() {
75
+ isDetecting.value = false;
76
+ hasFinishedTyping.value = true;
77
+ detectionAttempted.value = false;
78
+ }
79
+
80
+ return {
81
+ isDetecting: readonly(isDetecting),
82
+ hasFinishedTyping: readonly(hasFinishedTyping),
83
+ detectionAttempted: readonly(detectionAttempted),
84
+ markTyping,
85
+ markDetectionAttempt,
86
+ reset,
87
+ };
88
+ }
@@ -0,0 +1,29 @@
1
+ // Components
2
+ export { default as ATellInput } from './components/ATellInput.vue';
3
+ export { default as ACountrySelect } from './components/ACountrySelect.vue';
4
+ export { default as ACountryFlag } from './components/ACountryFlag.vue';
5
+
6
+ // Types, variants, defaults
7
+ export {
8
+ aTellInputVariants,
9
+ DEFAULT_ERROR_MESSAGES,
10
+ DEFAULT_MESSAGES,
11
+ resolveMessages,
12
+ type ATellInputProps,
13
+ type ATellInputSize,
14
+ type ATellInputVariants,
15
+ type ATellInputDir,
16
+ type TellInputMessages,
17
+ type TellInputMessagesInput,
18
+ } from './utils/types';
19
+ export { defaultFlagUrl, type FlagUrlBuilder } from './utils/flag-url';
20
+
21
+ // i18n — alternative-numeral normalization
22
+ export { normalizeDigits, LOCALE_DIGIT_RANGES } from './utils/digits';
23
+
24
+ // Composables — co-located with the components since they're tel-input specific.
25
+ export * from './composables/usePhoneValidation';
26
+ export * from './composables/useCountryDetection';
27
+ export * from './composables/useCountryMatching';
28
+ export * from './composables/useTypingPhase';
29
+ export * from './composables/useTellInputValidation';
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Alternative-numeral support. Phone numbers are routinely entered with the digits of the
3
+ * user's own script — Arabic-Indic, Persian/Urdu, Devanagari, Bengali. `libphonenumber-js`
4
+ * and our own `\d` cleanup only understand ASCII `0-9`, so anything else silently becomes
5
+ * an empty number. `normalizeDigits` folds those scripts down to ASCII before validation.
6
+ */
7
+
8
+ /**
9
+ * Base code points of contiguous decimal-digit blocks. Each block runs `base`‥`base+9`
10
+ * for digit `0`‥`9`, so the ASCII digit is `codePoint - base`. Add a script by appending
11
+ * one entry here.
12
+ */
13
+ export const LOCALE_DIGIT_RANGES: { name: string; base: number }[] = [
14
+ { name: 'arabic-indic', base: 0x0660 }, // ٠١٢٣٤٥٦٧٨٩
15
+ { name: 'extended-arabic', base: 0x06f0 }, // ۰۱۲۳۴۵۶۷۸۹ — Persian / Urdu
16
+ { name: 'devanagari', base: 0x0966 }, // ०१२३४५६७८९
17
+ { name: 'bengali', base: 0x09e6 }, // ০১২৩৪৫৬৭৮৯
18
+ ];
19
+
20
+ /** Lookup of every non-ASCII digit code point → its ASCII character. */
21
+ const DIGIT_MAP: Map<number, string> = (() => {
22
+ const map = new Map<number, string>();
23
+ for (const { base } of LOCALE_DIGIT_RANGES) {
24
+ for (let d = 0; d <= 9; d++) map.set(base + d, String(d));
25
+ }
26
+ return map;
27
+ })();
28
+
29
+ /**
30
+ * Replace any supported non-ASCII decimal digit with its ASCII equivalent. Every other
31
+ * character (spaces, `+`, separators, letters) is left untouched — callers still run their
32
+ * own `\D` cleanup afterwards.
33
+ */
34
+ export function normalizeDigits(input: string): string {
35
+ const str = String(input ?? '');
36
+ let out = '';
37
+ for (const ch of str) {
38
+ const cp = ch.codePointAt(0);
39
+ out += (cp != null && DIGIT_MAP.get(cp)) || ch;
40
+ }
41
+ return out;
42
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Default flag URL builder — flagcdn.com hosts PNG flags at multiple widths and is
3
+ * generous with caching + no API key required. Swap via the `flagUrl` prop on
4
+ * ATellInput / ACountrySelect / ACountryFlag to use any other source.
5
+ */
6
+ export function defaultFlagUrl(iso2: string, width = 40): string {
7
+ return `https://flagcdn.com/w${width}/${iso2.toLowerCase()}.png`;
8
+ }
9
+
10
+ export type FlagUrlBuilder = (iso2: string, width: number) => string;
@@ -0,0 +1,169 @@
1
+ import type { HTMLAttributes } from 'vue';
2
+ import { cva, type VariantProps } from 'class-variance-authority';
3
+ import type { DetectionStrategy } from '../composables/useCountryDetection';
4
+ import type { PhoneValidationReason } from '../composables/usePhoneValidation';
5
+ import { controlHeight, controlTextSize, type Size } from '@/utils';
6
+
7
+ /** Alias for the shared `Size` scale — kept for backwards-friendly naming. */
8
+ export type ATellInputSize = Size;
9
+
10
+ export const aTellInputVariants = cva(
11
+ // items-center (not items-stretch) so #prefix/#suffix icons centre vertically without distortion.
12
+ // The country trigger button and the input element both carry `h-full`, so they still fill the
13
+ // field height regardless of this setting.
14
+ 'border-input bg-background ring-offset-background focus-within:ring-ring flex w-full items-center overflow-hidden rounded-md border shadow-sm transition-colors focus-within:ring-1 has-[input:disabled]:cursor-not-allowed has-[input:disabled]:opacity-50',
15
+ {
16
+ variants: {
17
+ size: {
18
+ xs: `${controlHeight.xs} ${controlTextSize.xs}`,
19
+ sm: `${controlHeight.sm} ${controlTextSize.sm}`,
20
+ md: `${controlHeight.md} ${controlTextSize.md}`,
21
+ lg: `${controlHeight.lg} ${controlTextSize.lg}`,
22
+ xl: `${controlHeight.xl} ${controlTextSize.xl}`,
23
+ },
24
+ },
25
+ defaultVariants: { size: 'md' },
26
+ }
27
+ );
28
+
29
+ export type ATellInputVariants = VariantProps<typeof aTellInputVariants>;
30
+
31
+ /** Text direction for the field. `'auto'` (or omitting the prop) inherits from the
32
+ * nearest `[dir]` ancestor / `<html dir>`; `'ltr'` / `'rtl'` force it. */
33
+ export type ATellInputDir = 'ltr' | 'rtl' | 'auto';
34
+
35
+ /**
36
+ * Every user-facing string in the tell-input UI, bundled so a consumer can localize the
37
+ * component in one prop. Each key has an English default in {@link DEFAULT_MESSAGES}.
38
+ */
39
+ export interface TellInputMessages {
40
+ /** Placeholder of the country-picker search box. */
41
+ searchPlaceholder: string;
42
+ /** Shown when a search yields no countries. */
43
+ emptyText: string;
44
+ /** Shown while the country list is loading. */
45
+ loadingText: string;
46
+ /** Header of the "Suggested" group (current + recent picks). */
47
+ suggestedLabel: string;
48
+ /** Header of the full country list. */
49
+ allCountriesLabel: string;
50
+ /** Validation error text, keyed by reason. */
51
+ errorMessages: Record<PhoneValidationReason, string>;
52
+ /** Prefix of the country trigger's `aria-label`, e.g. `"Country: Egypt"`. */
53
+ countryLabel: string;
54
+ /** `aria-label` of the country trigger when no country is selected. */
55
+ selectCountryLabel: string;
56
+ /** `aria-label` of the phone input element. */
57
+ phoneInputLabel: string;
58
+ }
59
+
60
+ /** Partial override shape for the `messages` prop — every key (and every error reason) is optional. */
61
+ export type TellInputMessagesInput = Partial<Omit<TellInputMessages, 'errorMessages'>> & {
62
+ errorMessages?: Partial<Record<PhoneValidationReason, string>>;
63
+ };
64
+
65
+ export interface ATellInputProps {
66
+ class?: HTMLAttributes['class'];
67
+ placeholder?: string;
68
+ disabled?: boolean;
69
+ loading?: boolean;
70
+ size?: ATellInputSize;
71
+ /**
72
+ * Text direction. Omit (or pass `'auto'`) to inherit from the page — RTL pages get an
73
+ * RTL field automatically. Pass `'ltr'` / `'rtl'` to force it.
74
+ */
75
+ dir?: ATellInputDir;
76
+ /**
77
+ * BCP-47 locale (e.g. `'ar'`, `'fr'`). When set, country names render localized via
78
+ * `Intl.DisplayNames` and the format hint uses the locale's numerals.
79
+ */
80
+ locale?: string;
81
+ /**
82
+ * Localized UI strings. A single bag covering the picker, validation errors, and a11y
83
+ * labels. Individual props (`searchPlaceholder`, `emptyText`, `loadingText`,
84
+ * `errorMessages`) take precedence over the matching `messages` key when both are set.
85
+ */
86
+ messages?: TellInputMessagesInput;
87
+ /**
88
+ * Whitelist of allowed dial-digit codes (no `+`), e.g. `['20', '966']`.
89
+ * Countries outside this list are still shown in the picker but rendered as disabled.
90
+ */
91
+ allowedDialCodes?: string[];
92
+ /** Light up the field's validation styling — coloured border + ring on the input and the
93
+ * error message line below — when the number is valid / invalid. Default `false`, so the
94
+ * field stays neutral and validation surfacing is left to the consumer (via the
95
+ * `validation` ref exposure). */
96
+ showValidation?: boolean;
97
+ /** Show the green check / red alert icon at the end of the field. Default `false`; opt
98
+ * in with `true`. Independent of `showValidation` — you can show the icon without the
99
+ * coloured field, or vice versa. The slots `#valid-icon` / `#error-icon` still apply. */
100
+ showValidationIcon?: boolean;
101
+ /**
102
+ * Country auto-detect strategy. Defaults to `'auto'` — try IP geolocation first, then
103
+ * timezone, then `navigator.language`, finally `defaultCountry`.
104
+ */
105
+ detectCountry?: DetectionStrategy;
106
+ /**
107
+ * Initial country. Accepts either an ISO2 code (`'EG'`) or a dial-digit string
108
+ * (`'20'`, `'+20'`). When set, the picker is visible at mount with this country
109
+ * pre-selected — overrides the hidden-until-detected default.
110
+ */
111
+ defaultCountry?: string;
112
+ /** Override the IP geolocation endpoint. Must return JSON with `country_code` or `country`. */
113
+ ipEndpoint?: string;
114
+ /** Localized strings for the country picker UI. */
115
+ searchPlaceholder?: string;
116
+ emptyText?: string;
117
+ loadingText?: string;
118
+ /** Error labels keyed by reason. Each gets a sensible English default. */
119
+ errorMessages?: Partial<Record<PhoneValidationReason, string>>;
120
+ /**
121
+ * When true, the country picker is hidden until a leading dial code is detected in the
122
+ * phone input. Every keystroke runs a longest-prefix match against known dial codes; on
123
+ * first match the picker reveals with that country and the matched dial digits are
124
+ * stripped from `phone`. Skips the onMount IP/timezone/locale detection chain.
125
+ */
126
+ detectFromInput?: boolean;
127
+ /**
128
+ * Debounce window (ms) for `detectFromInput` detection. Each keystroke schedules the
129
+ * libphonenumber parse + lookup; bursts of typing/paste collapse into a single call.
130
+ * Clearing the input is not debounced — the picker hides immediately. Default 150ms.
131
+ */
132
+ detectDebounceMs?: number;
133
+ }
134
+
135
+ export const DEFAULT_ERROR_MESSAGES: Record<PhoneValidationReason, string> = {
136
+ missing_country: 'Please select a country.',
137
+ country_not_supported: 'This country is not supported.',
138
+ phone_has_non_digits: 'Phone number can only contain digits.',
139
+ too_short: 'Phone number is too short.',
140
+ too_long: 'Phone number is too long.',
141
+ invalid_phone: 'Phone number is invalid.',
142
+ parse_failed: 'Could not parse phone number.',
143
+ };
144
+
145
+ /** English defaults for every {@link TellInputMessages} key. */
146
+ export const DEFAULT_MESSAGES: TellInputMessages = {
147
+ searchPlaceholder: 'Search country or +code…',
148
+ emptyText: 'No countries found.',
149
+ loadingText: 'Loading countries…',
150
+ suggestedLabel: 'Suggested',
151
+ allCountriesLabel: 'All countries',
152
+ errorMessages: DEFAULT_ERROR_MESSAGES,
153
+ countryLabel: 'Country',
154
+ selectCountryLabel: 'Select country',
155
+ phoneInputLabel: 'Phone number',
156
+ };
157
+
158
+ /**
159
+ * Merge a partial `messages` override onto the English defaults. Used internally by
160
+ * `ATellInput` to resolve a complete {@link TellInputMessages} object.
161
+ */
162
+ export function resolveMessages(input?: TellInputMessagesInput): TellInputMessages {
163
+ if (!input) return DEFAULT_MESSAGES;
164
+ return {
165
+ ...DEFAULT_MESSAGES,
166
+ ...input,
167
+ errorMessages: { ...DEFAULT_ERROR_MESSAGES, ...input.errorMessages },
168
+ };
169
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alikhalilll/ui",
3
- "version": "1.2.2",
3
+ "version": "1.2.4",
4
4
  "description": "Headless, shadcn-vue style Vue 3 component library — every component is its own importable subpath (`@alikhalilll/ui/tell-input`, `@alikhalilll/ui/popover`, …) so users pay only for what they actually use.",
5
5
  "license": "MIT",
6
6
  "author": "alikhalilll",
@@ -69,6 +69,9 @@
69
69
  "types": "./dist/index.d.ts",
70
70
  "files": [
71
71
  "dist",
72
+ "entries/**/*.vue",
73
+ "entries/**/*.ts",
74
+ "utils/**/*.ts",
72
75
  "entries/**/README.md",
73
76
  "README.md",
74
77
  "LICENSE"
package/utils/cn.ts ADDED
@@ -0,0 +1,6 @@
1
+ import { clsx, type ClassValue } from 'clsx';
2
+ import { twMerge } from 'tailwind-merge';
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs));
6
+ }
package/utils/index.ts ADDED
@@ -0,0 +1,10 @@
1
+ export { cn } from './cn';
2
+ export {
3
+ SIZES,
4
+ DEFAULT_SIZE,
5
+ controlHeight,
6
+ controlPaddingX,
7
+ controlTextSize,
8
+ controlHeightPx,
9
+ type Size,
10
+ } from './sizes';
package/utils/sizes.ts ADDED
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Shared size scale for every interactive UI component in @alikhalilll/ui.
3
+ *
4
+ * xs = 28px · sm = 36px · md = 43px (default) · lg = 52px · xl = 60px
5
+ *
6
+ * Use the {@link controlHeight}, {@link controlPaddingX}, {@link controlTextSize}
7
+ * maps when building a CVA variant so every component stays in lockstep.
8
+ */
9
+
10
+ export type Size = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
11
+
12
+ export const SIZES: readonly Size[] = ['xs', 'sm', 'md', 'lg', 'xl'] as const;
13
+
14
+ export const DEFAULT_SIZE: Size = 'md';
15
+
16
+ /** Tailwind height utility per size. md uses an arbitrary value because 43px isn't on the spacing scale. */
17
+ export const controlHeight: Record<Size, string> = {
18
+ xs: 'h-7',
19
+ sm: 'h-9',
20
+ md: 'h-[43px]',
21
+ lg: 'h-[52px]',
22
+ xl: 'h-[60px]',
23
+ };
24
+
25
+ export const controlPaddingX: Record<Size, string> = {
26
+ xs: 'px-2',
27
+ sm: 'px-2.5',
28
+ md: 'px-3',
29
+ lg: 'px-3.5',
30
+ xl: 'px-4',
31
+ };
32
+
33
+ export const controlTextSize: Record<Size, string> = {
34
+ xs: 'text-xs',
35
+ sm: 'text-sm',
36
+ md: 'text-sm',
37
+ lg: 'text-base',
38
+ xl: 'text-base',
39
+ };
40
+
41
+ /** Pixel values exposed so non-template code (icons, ResizeObserver, etc.) can read the height. */
42
+ export const controlHeightPx: Record<Size, number> = {
43
+ xs: 28,
44
+ sm: 36,
45
+ md: 43,
46
+ lg: 52,
47
+ xl: 60,
48
+ };