@urbicon-ui/blocks 6.37.0 → 6.37.2
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/dist/components/PinInput/PinInput.svelte +4 -0
- package/dist/components/PinInput/pin-input.variants.js +25 -29
- package/dist/components/QRCode/qr-code.variants.js +7 -1
- package/dist/components/TimeInput/TimeInput.svelte +22 -7
- package/dist/components/TimeInput/time-input.variants.js +26 -22
- package/dist/internal/field-chrome.d.ts +87 -0
- package/dist/internal/field-chrome.js +81 -0
- package/dist/primitives/Input/input.variants.js +24 -19
- package/package.json +3 -3
|
@@ -84,6 +84,10 @@
|
|
|
84
84
|
if (normalized !== (value ?? '')) {
|
|
85
85
|
value = normalized;
|
|
86
86
|
}
|
|
87
|
+
// Completeness must track the (possibly externally reset) value — a stale
|
|
88
|
+
// `true` after a parent-side clear would swallow the onComplete of the next
|
|
89
|
+
// single-emit fill (paste/autofill), the standard wrong-code retry flow.
|
|
90
|
+
wasComplete = normalized.length === length;
|
|
87
91
|
});
|
|
88
92
|
|
|
89
93
|
// Stable per-position keys — the index IS the cell identity, but a derived id
|
|
@@ -1,17 +1,23 @@
|
|
|
1
|
+
import { FIELD_DISABLED_FRAME, FIELD_LABEL, FIELD_LABEL_DISABLED, FIELD_MESSAGE_TONES, FIELD_NATIVE_DISABLED, FIELD_NATIVE_READONLY, FIELD_REQUIRED_LABEL, FIELD_SURFACE, FIELD_TRANSITION, fieldErrorFrame, fieldFocusRing, fieldIntentFrames, fieldSurfaceVariants } from '../../internal/field-chrome.js';
|
|
1
2
|
import { tv } from '../../utils/variants.js';
|
|
3
|
+
// Each cell is directly focusable, so the ring lives on the cell itself.
|
|
4
|
+
const focus = 'focus-visible';
|
|
5
|
+
const surface = fieldSurfaceVariants(focus);
|
|
6
|
+
const intents = fieldIntentFrames(focus);
|
|
2
7
|
export const pinInputVariants = tv({
|
|
3
8
|
slots: {
|
|
4
9
|
root: ['flex flex-col gap-1.5'],
|
|
5
|
-
label: [
|
|
10
|
+
label: [FIELD_LABEL],
|
|
6
11
|
group: ['flex items-center'],
|
|
7
12
|
cell: [
|
|
8
13
|
'box-border text-center font-medium tabular-nums caret-primary',
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
14
|
+
FIELD_SURFACE,
|
|
15
|
+
FIELD_TRANSITION,
|
|
16
|
+
// `focus-visible:z-10` lifts the focused cell's ring above its neighbours.
|
|
17
|
+
`focus-visible:outline-none ${fieldFocusRing(focus)} focus-visible:z-10`,
|
|
12
18
|
'hover:border-border-default',
|
|
13
|
-
|
|
14
|
-
|
|
19
|
+
FIELD_NATIVE_DISABLED,
|
|
20
|
+
FIELD_NATIVE_READONLY
|
|
15
21
|
],
|
|
16
22
|
separator: ['text-text-tertiary select-none'],
|
|
17
23
|
message: ['text-xs']
|
|
@@ -24,13 +30,9 @@ export const pinInputVariants = tv({
|
|
|
24
30
|
commit: { cell: 'rounded-commit' }
|
|
25
31
|
},
|
|
26
32
|
variant: {
|
|
27
|
-
outlined: { cell:
|
|
28
|
-
filled: {
|
|
29
|
-
|
|
30
|
-
},
|
|
31
|
-
ghost: {
|
|
32
|
-
cell: 'bg-transparent hover:bg-surface-subtle focus-visible:bg-surface-base focus-visible:border-border-subtle'
|
|
33
|
-
}
|
|
33
|
+
outlined: { cell: surface.outlined },
|
|
34
|
+
filled: { cell: surface.filled },
|
|
35
|
+
ghost: { cell: surface.ghost }
|
|
34
36
|
},
|
|
35
37
|
size: {
|
|
36
38
|
sm: {
|
|
@@ -53,20 +55,14 @@ export const pinInputVariants = tv({
|
|
|
53
55
|
},
|
|
54
56
|
intent: {
|
|
55
57
|
default: {},
|
|
56
|
-
success: {
|
|
57
|
-
|
|
58
|
-
}
|
|
59
|
-
warning: {
|
|
60
|
-
cell: 'border-warning focus-visible:border-warning focus-visible:ring-warning/20'
|
|
61
|
-
},
|
|
62
|
-
danger: {
|
|
63
|
-
cell: 'border-danger focus-visible:border-danger focus-visible:ring-danger/20'
|
|
64
|
-
}
|
|
58
|
+
success: { cell: intents.success },
|
|
59
|
+
warning: { cell: intents.warning },
|
|
60
|
+
danger: { cell: intents.danger }
|
|
65
61
|
},
|
|
66
62
|
disabled: {
|
|
67
63
|
true: {
|
|
68
|
-
cell:
|
|
69
|
-
label:
|
|
64
|
+
cell: FIELD_DISABLED_FRAME,
|
|
65
|
+
label: FIELD_LABEL_DISABLED
|
|
70
66
|
}
|
|
71
67
|
},
|
|
72
68
|
readonly: {
|
|
@@ -75,17 +71,17 @@ export const pinInputVariants = tv({
|
|
|
75
71
|
// Declared BEFORE `error` so the error tone wins the message-color bucket
|
|
76
72
|
// in every call shape — `{ error: true }` alone must read red.
|
|
77
73
|
messageType: {
|
|
78
|
-
error: { message:
|
|
79
|
-
helper: { message:
|
|
74
|
+
error: { message: FIELD_MESSAGE_TONES.error },
|
|
75
|
+
helper: { message: FIELD_MESSAGE_TONES.helper }
|
|
80
76
|
},
|
|
81
77
|
error: {
|
|
82
78
|
true: {
|
|
83
|
-
cell:
|
|
84
|
-
message:
|
|
79
|
+
cell: fieldErrorFrame(focus),
|
|
80
|
+
message: FIELD_MESSAGE_TONES.error
|
|
85
81
|
}
|
|
86
82
|
},
|
|
87
83
|
required: {
|
|
88
|
-
true: { label:
|
|
84
|
+
true: { label: FIELD_REQUIRED_LABEL }
|
|
89
85
|
}
|
|
90
86
|
},
|
|
91
87
|
compoundVariants: [
|
|
@@ -11,10 +11,16 @@ export const qrCodeVariants = tv({
|
|
|
11
11
|
variants: {
|
|
12
12
|
// Optional framing so the code can sit on a guaranteed-light card (the
|
|
13
13
|
// scan-safe default look) without the consumer hand-building one.
|
|
14
|
+
// `scheme-light` pins `color-scheme: light` on the card, so every
|
|
15
|
+
// `light-dark()` token inside resolves to its light value even in dark
|
|
16
|
+
// mode — without it the card ground flips dark and the `currentColor`
|
|
17
|
+
// modules invert (light-on-dark QR codes fail many scanners). The explicit
|
|
18
|
+
// `text-text-primary` re-derives the module colour under that scheme
|
|
19
|
+
// (inherited `color` is computed on the dark ancestor and would leak in).
|
|
14
20
|
frame: {
|
|
15
21
|
none: {},
|
|
16
22
|
card: {
|
|
17
|
-
root: 'bg-surface-base border border-border-subtle rounded-lg p-3 shadow-[var(--blocks-shadow-sm)]'
|
|
23
|
+
root: 'scheme-light bg-surface-base text-text-primary border border-border-subtle rounded-lg p-3 shadow-[var(--blocks-shadow-sm)]'
|
|
18
24
|
}
|
|
19
25
|
}
|
|
20
26
|
},
|
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
let hourEl = $state<HTMLInputElement>();
|
|
67
67
|
let minuteEl = $state<HTMLInputElement>();
|
|
68
68
|
let secondEl = $state<HTMLInputElement>();
|
|
69
|
-
let meridiemEl = $state<
|
|
69
|
+
let meridiemEl = $state<HTMLSpanElement>();
|
|
70
70
|
|
|
71
71
|
function pad(n: number): string {
|
|
72
72
|
return String(n).padStart(2, '0');
|
|
@@ -174,7 +174,7 @@
|
|
|
174
174
|
minuteEl,
|
|
175
175
|
withSeconds ? secondEl : undefined,
|
|
176
176
|
format === '12h' ? meridiemEl : undefined
|
|
177
|
-
].filter(Boolean) as (HTMLInputElement |
|
|
177
|
+
].filter(Boolean) as (HTMLInputElement | HTMLSpanElement)[]
|
|
178
178
|
);
|
|
179
179
|
|
|
180
180
|
function advanceFrom(el: HTMLElement) {
|
|
@@ -315,6 +315,10 @@
|
|
|
315
315
|
switch (e.key) {
|
|
316
316
|
case 'ArrowUp':
|
|
317
317
|
case 'ArrowDown':
|
|
318
|
+
// The segment is a spinbutton on a span (html-aria forbids the role on
|
|
319
|
+
// <button>), so Enter/Space activation is wired manually.
|
|
320
|
+
case 'Enter':
|
|
321
|
+
case ' ':
|
|
318
322
|
e.preventDefault();
|
|
319
323
|
toggleMeridiem();
|
|
320
324
|
break;
|
|
@@ -331,7 +335,7 @@
|
|
|
331
335
|
case 'ArrowLeft':
|
|
332
336
|
case 'Backspace': {
|
|
333
337
|
e.preventDefault();
|
|
334
|
-
const idx = order.indexOf(e.currentTarget as
|
|
338
|
+
const idx = order.indexOf(e.currentTarget as HTMLSpanElement);
|
|
335
339
|
if (idx > 0) order[idx - 1].focus();
|
|
336
340
|
break;
|
|
337
341
|
}
|
|
@@ -478,11 +482,22 @@
|
|
|
478
482
|
/>
|
|
479
483
|
{/if}
|
|
480
484
|
{#if format === '12h'}
|
|
481
|
-
|
|
485
|
+
<!-- A spinbutton (not a button): aria-label on a button would OVERRIDE its
|
|
486
|
+
AM/PM content, so the current state was never announced. As a
|
|
487
|
+
spinbutton the state travels via aria-valuetext — same semantics as
|
|
488
|
+
the sibling segments — and html-aria only permits the role on a
|
|
489
|
+
non-button host, hence the span with manual focus/activation. -->
|
|
490
|
+
<span
|
|
482
491
|
bind:this={meridiemEl}
|
|
483
|
-
|
|
484
|
-
{disabled}
|
|
492
|
+
role="spinbutton"
|
|
493
|
+
tabindex={disabled ? -1 : 0}
|
|
485
494
|
aria-label={bt('accessibility.timeMeridiem')}
|
|
495
|
+
aria-valuemin={0}
|
|
496
|
+
aria-valuemax={1}
|
|
497
|
+
aria-valuenow={meridiem === 'AM' ? 0 : 1}
|
|
498
|
+
aria-valuetext={meridiem}
|
|
499
|
+
aria-disabled={disabled ? 'true' : undefined}
|
|
500
|
+
aria-readonly={readonly ? 'true' : undefined}
|
|
486
501
|
class={unstyled
|
|
487
502
|
? (slotClasses?.meridiem ?? '')
|
|
488
503
|
: styles.meridiem({ class: slotClasses?.meridiem })}
|
|
@@ -490,7 +505,7 @@
|
|
|
490
505
|
onkeydown={handleMeridiemKeydown}
|
|
491
506
|
>
|
|
492
507
|
{meridiem}
|
|
493
|
-
</
|
|
508
|
+
</span>
|
|
494
509
|
{/if}
|
|
495
510
|
</div>
|
|
496
511
|
|
|
@@ -1,17 +1,23 @@
|
|
|
1
|
+
import { FIELD_DISABLED_FRAME, FIELD_LABEL, FIELD_LABEL_DISABLED, FIELD_MESSAGE_TONES, FIELD_REQUIRED_LABEL, FIELD_SURFACE, FIELD_TRANSITION, fieldErrorFrame, fieldFocusRing, fieldIntentFrames, fieldSurfaceVariants } from '../../internal/field-chrome.js';
|
|
1
2
|
import { tv } from '../../utils/variants.js';
|
|
3
|
+
// Borderless segments live inside a bordered container, so the ring lights the
|
|
4
|
+
// whole field via focus-within rather than focus-visible on one element.
|
|
5
|
+
const focus = 'focus-within';
|
|
6
|
+
const surface = fieldSurfaceVariants(focus);
|
|
7
|
+
const intents = fieldIntentFrames(focus);
|
|
2
8
|
export const timeInputVariants = tv({
|
|
3
9
|
slots: {
|
|
4
10
|
wrapper: ['flex flex-col gap-1.5 w-full'],
|
|
5
|
-
label: [
|
|
11
|
+
label: [FIELD_LABEL],
|
|
6
12
|
// The bordered container reads as a single field; the segment inputs inside
|
|
7
13
|
// are borderless. The focus ring lives here via focus-within so tabbing
|
|
8
14
|
// between segments keeps the whole field lit.
|
|
9
15
|
field: [
|
|
10
16
|
'inline-flex items-center box-border w-fit',
|
|
11
|
-
|
|
12
|
-
|
|
17
|
+
FIELD_SURFACE,
|
|
18
|
+
FIELD_TRANSITION,
|
|
13
19
|
'hover:border-border-default',
|
|
14
|
-
|
|
20
|
+
fieldFocusRing(focus)
|
|
15
21
|
],
|
|
16
22
|
icon: ['pointer-events-none inline-flex items-center justify-center text-text-tertiary'],
|
|
17
23
|
segment: [
|
|
@@ -25,7 +31,8 @@ export const timeInputVariants = tv({
|
|
|
25
31
|
meridiem: [
|
|
26
32
|
'ml-1 font-medium text-text-secondary rounded-sm cursor-pointer select-none',
|
|
27
33
|
'hover:bg-surface-hover focus-visible:outline-none focus-visible:bg-primary-subtle',
|
|
28
|
-
|
|
34
|
+
// The segment is a span-hosted spinbutton — no native :disabled state.
|
|
35
|
+
'aria-disabled:cursor-not-allowed aria-disabled:opacity-50'
|
|
29
36
|
],
|
|
30
37
|
message: ['text-xs']
|
|
31
38
|
},
|
|
@@ -37,13 +44,9 @@ export const timeInputVariants = tv({
|
|
|
37
44
|
commit: { field: 'rounded-commit' }
|
|
38
45
|
},
|
|
39
46
|
variant: {
|
|
40
|
-
outlined: { field:
|
|
41
|
-
filled: {
|
|
42
|
-
|
|
43
|
-
},
|
|
44
|
-
ghost: {
|
|
45
|
-
field: 'bg-transparent hover:bg-surface-subtle focus-within:bg-surface-base focus-within:border-border-subtle'
|
|
46
|
-
}
|
|
47
|
+
outlined: { field: surface.outlined },
|
|
48
|
+
filled: { field: surface.filled },
|
|
49
|
+
ghost: { field: surface.ghost }
|
|
47
50
|
},
|
|
48
51
|
size: {
|
|
49
52
|
sm: {
|
|
@@ -68,31 +71,32 @@ export const timeInputVariants = tv({
|
|
|
68
71
|
},
|
|
69
72
|
intent: {
|
|
70
73
|
default: {},
|
|
71
|
-
success: { field:
|
|
72
|
-
warning: { field:
|
|
73
|
-
danger: { field:
|
|
74
|
+
success: { field: intents.success },
|
|
75
|
+
warning: { field: intents.warning },
|
|
76
|
+
danger: { field: intents.danger }
|
|
74
77
|
},
|
|
75
78
|
disabled: {
|
|
76
79
|
true: {
|
|
77
|
-
field:
|
|
78
|
-
label:
|
|
80
|
+
field: FIELD_DISABLED_FRAME,
|
|
81
|
+
label: FIELD_LABEL_DISABLED
|
|
79
82
|
}
|
|
80
83
|
},
|
|
81
84
|
readonly: {
|
|
85
|
+
// No `cursor-default` here — the container's segments carry their own cursor.
|
|
82
86
|
true: { field: 'bg-surface-subtle' }
|
|
83
87
|
},
|
|
84
88
|
messageType: {
|
|
85
|
-
error: { message:
|
|
86
|
-
helper: { message:
|
|
89
|
+
error: { message: FIELD_MESSAGE_TONES.error },
|
|
90
|
+
helper: { message: FIELD_MESSAGE_TONES.helper }
|
|
87
91
|
},
|
|
88
92
|
error: {
|
|
89
93
|
true: {
|
|
90
|
-
field:
|
|
91
|
-
message:
|
|
94
|
+
field: fieldErrorFrame(focus),
|
|
95
|
+
message: FIELD_MESSAGE_TONES.error
|
|
92
96
|
}
|
|
93
97
|
},
|
|
94
98
|
required: {
|
|
95
|
-
true: { label:
|
|
99
|
+
true: { label: FIELD_REQUIRED_LABEL }
|
|
96
100
|
},
|
|
97
101
|
fullWidth: {
|
|
98
102
|
true: { field: 'w-full justify-start' }
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared style fragments for the bordered field frame.
|
|
3
|
+
*
|
|
4
|
+
* Input, PinInput and TimeInput (and NumberInput, via its Input composition)
|
|
5
|
+
* all render the same field chrome — the neutral resting frame, the focus ring,
|
|
6
|
+
* the `outlined | filled | ghost` surfaces, the intent colours, the disabled /
|
|
7
|
+
* error states and the label / message scaffolding. Before this module each of
|
|
8
|
+
* the three `*.variants.ts` hand-copied those class strings, so a token fix had
|
|
9
|
+
* to be applied in three places and drift crept in silently.
|
|
10
|
+
*
|
|
11
|
+
* These exports are the single source for the shared tokens. They are composed
|
|
12
|
+
* back into each `tv()` config at the EXACT positions the inlined strings held,
|
|
13
|
+
* so the resolved (flattened) class output is byte-identical — proven by the
|
|
14
|
+
* render-identity matrix diff in the debt-fix-wave-5 (40 960 Input / 2 304 Pin /
|
|
15
|
+
* 4 608 TimeInput combinations, zero diff). This is a pure de-duplication, not a
|
|
16
|
+
* behaviour change.
|
|
17
|
+
*
|
|
18
|
+
* The one axis of genuine divergence is the focus mechanism: Input and PinInput
|
|
19
|
+
* put the ring on the focusable element itself (`focus-visible:`), while
|
|
20
|
+
* TimeInput hosts borderless segments inside a bordered container and lights the
|
|
21
|
+
* whole field via `focus-within:`. That is why the ring / variant / intent /
|
|
22
|
+
* error fragments are parameterised by {@link FieldFocus} rather than fixed.
|
|
23
|
+
*
|
|
24
|
+
* NOT covered here (deliberately component-local): the per-component frame
|
|
25
|
+
* prefix (Input's `w-full`, PinInput's cell content styles, TimeInput's
|
|
26
|
+
* `inline-flex` container), Input's `underline` variant and `placeholder`
|
|
27
|
+
* colour, PinInput's `focus-visible:z-10`, TimeInput's `fullWidth` and its
|
|
28
|
+
* cursor-free readonly. The shared label/message MARKUP in the `.svelte` files
|
|
29
|
+
* is a separate, larger cut (see technical-debt "Field chrome…" part b).
|
|
30
|
+
*
|
|
31
|
+
* This is not a component and imports nothing from other component dirs, so it
|
|
32
|
+
* is exempt from the cross-component import guard (imports-lint treats
|
|
33
|
+
* `internal/**` as the extraction target).
|
|
34
|
+
*/
|
|
35
|
+
/** The field's colour/border/shadow transition — the longest shared string. */
|
|
36
|
+
export declare const FIELD_TRANSITION = "transition-[color,background-color,border-color,box-shadow] duration-[var(--blocks-duration-fast)] ease-out";
|
|
37
|
+
/** The neutral resting frame: border + primary text + base surface. Input
|
|
38
|
+
* appends its own `placeholder:` colour after this. */
|
|
39
|
+
export declare const FIELD_SURFACE = "border text-text-primary bg-surface-base";
|
|
40
|
+
/** `:disabled` / `:read-only` pseudo-class fallbacks on a NATIVE field element
|
|
41
|
+
* (Input's `base`, PinInput's `cell`). TimeInput's frame is a container whose
|
|
42
|
+
* segments carry their own states, so it omits these. */
|
|
43
|
+
export declare const FIELD_NATIVE_DISABLED = "disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-surface-subtle";
|
|
44
|
+
export declare const FIELD_NATIVE_READONLY = "read-only:bg-surface-subtle read-only:cursor-default";
|
|
45
|
+
/**
|
|
46
|
+
* Where the focus ring lives: `focus-visible:` on a directly-focusable field
|
|
47
|
+
* (Input, PinInput cell), `focus-within:` on a container that hosts focusable
|
|
48
|
+
* segments (TimeInput).
|
|
49
|
+
*/
|
|
50
|
+
export type FieldFocus = 'focus-visible' | 'focus-within';
|
|
51
|
+
/** The primary focus ring (border + 2px ring at 20% alpha), for the given mode. */
|
|
52
|
+
export declare const fieldFocusRing: (f: FieldFocus) => string;
|
|
53
|
+
/**
|
|
54
|
+
* The `outlined | filled | ghost` surface values, parameterised by focus mode.
|
|
55
|
+
* `outlined` is a constant; consumers add their own extra variants (Input's
|
|
56
|
+
* `underline`) alongside.
|
|
57
|
+
*/
|
|
58
|
+
export declare const fieldSurfaceVariants: (f: FieldFocus) => {
|
|
59
|
+
outlined: string;
|
|
60
|
+
filled: string;
|
|
61
|
+
ghost: string;
|
|
62
|
+
};
|
|
63
|
+
/**
|
|
64
|
+
* The intent frame values (border + focus ring tint) for success/warning/danger,
|
|
65
|
+
* parameterised by focus mode. `default` carries no frame class, so callers keep
|
|
66
|
+
* their own empty `default: {}` slot entry.
|
|
67
|
+
*/
|
|
68
|
+
export declare const fieldIntentFrames: (f: FieldFocus) => {
|
|
69
|
+
success: string;
|
|
70
|
+
warning: string;
|
|
71
|
+
danger: string;
|
|
72
|
+
};
|
|
73
|
+
/** The error frame value (danger border + ring), parameterised by focus mode. */
|
|
74
|
+
export declare const fieldErrorFrame: (f: FieldFocus) => string;
|
|
75
|
+
/** Frame classes when the whole control is disabled (the `disabled` variant). */
|
|
76
|
+
export declare const FIELD_DISABLED_FRAME = "opacity-50 cursor-not-allowed bg-surface-disabled pointer-events-none";
|
|
77
|
+
/** The field label base, shared verbatim across all three fields. */
|
|
78
|
+
export declare const FIELD_LABEL = "block font-medium text-text-secondary text-sm";
|
|
79
|
+
/** Label colour when the control is disabled. */
|
|
80
|
+
export declare const FIELD_LABEL_DISABLED = "text-text-disabled";
|
|
81
|
+
/** Required-marker asterisk appended after the label. */
|
|
82
|
+
export declare const FIELD_REQUIRED_LABEL = "after:content-['*'] after:ml-1 after:text-danger";
|
|
83
|
+
/** The two `messageType` message tones (error wins the fold when both apply). */
|
|
84
|
+
export declare const FIELD_MESSAGE_TONES: {
|
|
85
|
+
readonly error: "text-danger";
|
|
86
|
+
readonly helper: "text-text-tertiary";
|
|
87
|
+
};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared style fragments for the bordered field frame.
|
|
3
|
+
*
|
|
4
|
+
* Input, PinInput and TimeInput (and NumberInput, via its Input composition)
|
|
5
|
+
* all render the same field chrome — the neutral resting frame, the focus ring,
|
|
6
|
+
* the `outlined | filled | ghost` surfaces, the intent colours, the disabled /
|
|
7
|
+
* error states and the label / message scaffolding. Before this module each of
|
|
8
|
+
* the three `*.variants.ts` hand-copied those class strings, so a token fix had
|
|
9
|
+
* to be applied in three places and drift crept in silently.
|
|
10
|
+
*
|
|
11
|
+
* These exports are the single source for the shared tokens. They are composed
|
|
12
|
+
* back into each `tv()` config at the EXACT positions the inlined strings held,
|
|
13
|
+
* so the resolved (flattened) class output is byte-identical — proven by the
|
|
14
|
+
* render-identity matrix diff in the debt-fix-wave-5 (40 960 Input / 2 304 Pin /
|
|
15
|
+
* 4 608 TimeInput combinations, zero diff). This is a pure de-duplication, not a
|
|
16
|
+
* behaviour change.
|
|
17
|
+
*
|
|
18
|
+
* The one axis of genuine divergence is the focus mechanism: Input and PinInput
|
|
19
|
+
* put the ring on the focusable element itself (`focus-visible:`), while
|
|
20
|
+
* TimeInput hosts borderless segments inside a bordered container and lights the
|
|
21
|
+
* whole field via `focus-within:`. That is why the ring / variant / intent /
|
|
22
|
+
* error fragments are parameterised by {@link FieldFocus} rather than fixed.
|
|
23
|
+
*
|
|
24
|
+
* NOT covered here (deliberately component-local): the per-component frame
|
|
25
|
+
* prefix (Input's `w-full`, PinInput's cell content styles, TimeInput's
|
|
26
|
+
* `inline-flex` container), Input's `underline` variant and `placeholder`
|
|
27
|
+
* colour, PinInput's `focus-visible:z-10`, TimeInput's `fullWidth` and its
|
|
28
|
+
* cursor-free readonly. The shared label/message MARKUP in the `.svelte` files
|
|
29
|
+
* is a separate, larger cut (see technical-debt "Field chrome…" part b).
|
|
30
|
+
*
|
|
31
|
+
* This is not a component and imports nothing from other component dirs, so it
|
|
32
|
+
* is exempt from the cross-component import guard (imports-lint treats
|
|
33
|
+
* `internal/**` as the extraction target).
|
|
34
|
+
*/
|
|
35
|
+
/** The field's colour/border/shadow transition — the longest shared string. */
|
|
36
|
+
export const FIELD_TRANSITION = 'transition-[color,background-color,border-color,box-shadow] duration-[var(--blocks-duration-fast)] ease-out';
|
|
37
|
+
/** The neutral resting frame: border + primary text + base surface. Input
|
|
38
|
+
* appends its own `placeholder:` colour after this. */
|
|
39
|
+
export const FIELD_SURFACE = 'border text-text-primary bg-surface-base';
|
|
40
|
+
/** `:disabled` / `:read-only` pseudo-class fallbacks on a NATIVE field element
|
|
41
|
+
* (Input's `base`, PinInput's `cell`). TimeInput's frame is a container whose
|
|
42
|
+
* segments carry their own states, so it omits these. */
|
|
43
|
+
export const FIELD_NATIVE_DISABLED = 'disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-surface-subtle';
|
|
44
|
+
export const FIELD_NATIVE_READONLY = 'read-only:bg-surface-subtle read-only:cursor-default';
|
|
45
|
+
/** The primary focus ring (border + 2px ring at 20% alpha), for the given mode. */
|
|
46
|
+
export const fieldFocusRing = (f) => `${f}:border-primary ${f}:ring-2 ${f}:ring-primary/20`;
|
|
47
|
+
/**
|
|
48
|
+
* The `outlined | filled | ghost` surface values, parameterised by focus mode.
|
|
49
|
+
* `outlined` is a constant; consumers add their own extra variants (Input's
|
|
50
|
+
* `underline`) alongside.
|
|
51
|
+
*/
|
|
52
|
+
export const fieldSurfaceVariants = (f) => ({
|
|
53
|
+
outlined: 'border-border-subtle',
|
|
54
|
+
filled: `bg-surface-interactive border-transparent hover:bg-surface-hover ${f}:bg-surface-base`,
|
|
55
|
+
ghost: `bg-transparent hover:bg-surface-subtle ${f}:bg-surface-base ${f}:border-border-subtle`
|
|
56
|
+
});
|
|
57
|
+
/**
|
|
58
|
+
* The intent frame values (border + focus ring tint) for success/warning/danger,
|
|
59
|
+
* parameterised by focus mode. `default` carries no frame class, so callers keep
|
|
60
|
+
* their own empty `default: {}` slot entry.
|
|
61
|
+
*/
|
|
62
|
+
export const fieldIntentFrames = (f) => ({
|
|
63
|
+
success: `border-success ${f}:border-success ${f}:ring-success/20`,
|
|
64
|
+
warning: `border-warning ${f}:border-warning ${f}:ring-warning/20`,
|
|
65
|
+
danger: `border-danger ${f}:border-danger ${f}:ring-danger/20`
|
|
66
|
+
});
|
|
67
|
+
/** The error frame value (danger border + ring), parameterised by focus mode. */
|
|
68
|
+
export const fieldErrorFrame = (f) => `border-danger ${f}:border-danger ${f}:ring-danger/20`;
|
|
69
|
+
/** Frame classes when the whole control is disabled (the `disabled` variant). */
|
|
70
|
+
export const FIELD_DISABLED_FRAME = 'opacity-50 cursor-not-allowed bg-surface-disabled pointer-events-none';
|
|
71
|
+
/** The field label base, shared verbatim across all three fields. */
|
|
72
|
+
export const FIELD_LABEL = 'block font-medium text-text-secondary text-sm';
|
|
73
|
+
/** Label colour when the control is disabled. */
|
|
74
|
+
export const FIELD_LABEL_DISABLED = 'text-text-disabled';
|
|
75
|
+
/** Required-marker asterisk appended after the label. */
|
|
76
|
+
export const FIELD_REQUIRED_LABEL = "after:content-['*'] after:ml-1 after:text-danger";
|
|
77
|
+
/** The two `messageType` message tones (error wins the fold when both apply). */
|
|
78
|
+
export const FIELD_MESSAGE_TONES = {
|
|
79
|
+
error: 'text-danger',
|
|
80
|
+
helper: 'text-text-tertiary'
|
|
81
|
+
};
|
|
@@ -1,18 +1,23 @@
|
|
|
1
|
+
import { FIELD_DISABLED_FRAME, FIELD_LABEL, FIELD_LABEL_DISABLED, FIELD_MESSAGE_TONES, FIELD_NATIVE_DISABLED, FIELD_NATIVE_READONLY, FIELD_REQUIRED_LABEL, FIELD_SURFACE, FIELD_TRANSITION, fieldErrorFrame, fieldFocusRing, fieldIntentFrames, fieldSurfaceVariants } from '../../internal/field-chrome.js';
|
|
1
2
|
import { tv } from '../../utils/variants.js';
|
|
3
|
+
// Input and PinInput light the focusable element itself; TimeInput its container.
|
|
4
|
+
const focus = 'focus-visible';
|
|
5
|
+
const surface = fieldSurfaceVariants(focus);
|
|
6
|
+
const intents = fieldIntentFrames(focus);
|
|
2
7
|
export const inputVariants = tv({
|
|
3
8
|
slots: {
|
|
4
9
|
wrapper: ['flex flex-col w-full gap-1.5'],
|
|
5
10
|
container: ['relative flex items-center'],
|
|
6
11
|
base: [
|
|
7
|
-
|
|
12
|
+
`w-full box-border ${FIELD_TRANSITION}`,
|
|
8
13
|
// Radius driven by `tier` axis below.
|
|
9
14
|
'focus-visible:outline-none',
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
15
|
+
`${FIELD_SURFACE} placeholder:text-text-quaternary`,
|
|
16
|
+
`hover:border-border-default ${fieldFocusRing(focus)}`,
|
|
17
|
+
FIELD_NATIVE_DISABLED,
|
|
18
|
+
FIELD_NATIVE_READONLY
|
|
14
19
|
],
|
|
15
|
-
label: [
|
|
20
|
+
label: [FIELD_LABEL],
|
|
16
21
|
message: ['text-xs mt-1.5'],
|
|
17
22
|
iconContainer: [
|
|
18
23
|
'absolute top-0 bottom-0 flex items-center justify-center z-10 pointer-events-none'
|
|
@@ -40,13 +45,13 @@ export const inputVariants = tv({
|
|
|
40
45
|
},
|
|
41
46
|
variant: {
|
|
42
47
|
outlined: {
|
|
43
|
-
base:
|
|
48
|
+
base: surface.outlined
|
|
44
49
|
},
|
|
45
50
|
filled: {
|
|
46
|
-
base:
|
|
51
|
+
base: surface.filled
|
|
47
52
|
},
|
|
48
53
|
ghost: {
|
|
49
|
-
base:
|
|
54
|
+
base: surface.ghost
|
|
50
55
|
},
|
|
51
56
|
underline: {
|
|
52
57
|
base: 'bg-transparent border-0 border-b-2 border-border-subtle rounded-none focus-visible:ring-0'
|
|
@@ -94,19 +99,19 @@ export const inputVariants = tv({
|
|
|
94
99
|
intent: {
|
|
95
100
|
default: {},
|
|
96
101
|
success: {
|
|
97
|
-
base:
|
|
102
|
+
base: intents.success
|
|
98
103
|
},
|
|
99
104
|
warning: {
|
|
100
|
-
base:
|
|
105
|
+
base: intents.warning
|
|
101
106
|
},
|
|
102
107
|
danger: {
|
|
103
|
-
base:
|
|
108
|
+
base: intents.danger
|
|
104
109
|
}
|
|
105
110
|
},
|
|
106
111
|
disabled: {
|
|
107
112
|
true: {
|
|
108
|
-
base:
|
|
109
|
-
label:
|
|
113
|
+
base: FIELD_DISABLED_FRAME,
|
|
114
|
+
label: FIELD_LABEL_DISABLED
|
|
110
115
|
}
|
|
111
116
|
},
|
|
112
117
|
readonly: {
|
|
@@ -117,18 +122,18 @@ export const inputVariants = tv({
|
|
|
117
122
|
// Declared BEFORE `error` so the error tone wins the message-color
|
|
118
123
|
// bucket in every call shape — `{ error: true }` alone must read red.
|
|
119
124
|
messageType: {
|
|
120
|
-
error: { message:
|
|
121
|
-
helper: { message:
|
|
125
|
+
error: { message: FIELD_MESSAGE_TONES.error },
|
|
126
|
+
helper: { message: FIELD_MESSAGE_TONES.helper }
|
|
122
127
|
},
|
|
123
128
|
error: {
|
|
124
129
|
true: {
|
|
125
|
-
base:
|
|
126
|
-
message:
|
|
130
|
+
base: fieldErrorFrame(focus),
|
|
131
|
+
message: FIELD_MESSAGE_TONES.error
|
|
127
132
|
}
|
|
128
133
|
},
|
|
129
134
|
required: {
|
|
130
135
|
true: {
|
|
131
|
-
label:
|
|
136
|
+
label: FIELD_REQUIRED_LABEL
|
|
132
137
|
}
|
|
133
138
|
},
|
|
134
139
|
hasLeftIcon: { true: {} },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@urbicon-ui/blocks",
|
|
3
|
-
"version": "6.37.
|
|
3
|
+
"version": "6.37.2",
|
|
4
4
|
"description": "Svelte 5 UI component library with Tailwind CSS 4, OKLCH design tokens and zero runtime dependencies",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -93,8 +93,8 @@
|
|
|
93
93
|
"@sveltejs/package": "^2.5.8",
|
|
94
94
|
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
|
95
95
|
"@tailwindcss/vite": "^4.3.1",
|
|
96
|
-
"@urbicon-ui/i18n": "6.37.
|
|
97
|
-
"@urbicon-ui/shared-types": "6.37.
|
|
96
|
+
"@urbicon-ui/i18n": "6.37.2",
|
|
97
|
+
"@urbicon-ui/shared-types": "6.37.2",
|
|
98
98
|
"prettier": "^3.8.4",
|
|
99
99
|
"prettier-plugin-svelte": "^4.1.1",
|
|
100
100
|
"prettier-plugin-tailwindcss": "^0.8.0",
|