@cahyo-dimas/freeday 1.13.0 → 1.14.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.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,38 @@
3
3
  Semua perubahan penting dicatat di sini. Format longgar mengikuti
4
4
  [Keep a Changelog](https://keepachangelog.com/); tiap versi = git tag.
5
5
 
6
+ ## [1.14.0] — 2026-08-01
7
+ ### Added
8
+ - **Read-only state across the form controls (#39).** `input` / `textarea` gain a `[readonly]` rule —
9
+ full-contrast text on a muted surface with a softened border and no focus-ring escalation, distinct
10
+ from `:disabled` (which dims the value to placeholder-grey and drops it from tab order). A new
11
+ `readonly` prop lands on all six controlled select-type adapters — `FdyCombo`, `FdyCascade`,
12
+ `FdyDatepicker`, `FdyDateRange`, `FdyAutocomplete`, `FdyCfl` (Vue + React): the control stays focusable
13
+ and shows its value with `aria-readonly="true"` but can't be opened or changed (`FdyDatepicker`'s clear
14
+ button is suppressed too). Read-only ≠ disabled — it keeps tab order, full contrast, and stays
15
+ selectable / copyable / submittable, so a real value the user may not edit reads as data, not an empty
16
+ placeholder. Verified in-browser (readonly `opacity:1` vs disabled `0.5`; text at full `--color-text`).
17
+ - **Interaction-state coverage completed.** Added the missing `disabled` state to `timepicker`, `rating`
18
+ (`.fdy-rating--disabled`), `tree` (`[aria-disabled]` rows), `tabs`, `file-upload`
19
+ (`[aria-disabled]` / `.is-disabled`), and `datepicker`; and the missing `invalid` state to `rating`
20
+ (`.fdy-rating--error`). The vanilla datetime composer now reflects `data-fdy-disabled` /
21
+ `data-fdy-invalid` from its wrapper onto **both** child pickers so a datetime reads as one control
22
+ (applied on a macrotask, so it lands after both child triggers are built — a microtask would run
23
+ before the later-registered timepicker enhancer). All additive; no breaking changes.
24
+
25
+ ## [1.13.1] — 2026-08-01
26
+ ### Fixed
27
+ - **`FdyCombo` can be selected with the mouse again (#38).** Clicking an option did nothing — the
28
+ listbox closed and no `update:modelValue` fired (keyboard select still worked, which is why a
29
+ keyboard-only smoke test missed it). The option `<li>` isn't focusable, so a `mousedown` moved focus
30
+ off the combobox button; the root's `focusout` handler then closed the list — removing the `<li>` —
31
+ *before* `mouseup`, so the browser never generated a `click` and `choose()` was never reached. Added
32
+ `@mousedown.prevent` (Vue) / `onMouseDown` `preventDefault` (React) on the option so focus stays on the
33
+ button and the click lands — the same pattern the sibling `FdyDatepicker` / `FdyAutocomplete` (and the
34
+ vanilla `freeday-select.js`, fixed in v1.6.1) already use. Both adapters; no API change; keyboard and
35
+ hover paths untouched. Verified with trusted CDP mouse events (a synthetic `dispatchEvent` won't
36
+ reproduce it — untrusted events run no default action, so focus never moves).
37
+
6
38
  ## [1.13.0] — 2026-07-30
7
39
  ### Added
8
40
  - **`FdyDatepicker` clear affordance — an optional date can now be unset (#37A).** A new `clearable`
package/README.id.md CHANGED
@@ -5,7 +5,7 @@
5
5
  > **Lebih banyak _free day_ buat dev — UI kit-nya sudah siap pakai.**
6
6
 
7
7
  [![Live docs](https://img.shields.io/badge/docs-live-2050d8?style=flat-square)](https://cahyo-dimas.github.io/freeday-ui-kit/)
8
- [![Release](https://img.shields.io/badge/release-v1.13.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v1.13.0)
8
+ [![Release](https://img.shields.io/badge/release-v1.14.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v1.14.0)
9
9
 
10
10
  UI KIT yang token-driven & framework-agnostic — satu sumber kebenaran untuk warna, tipografi,
11
11
  spasi, dan komponen. Blueprint: `docs/superpowers/specs/2026-07-21-freeday-ui-kit-design.md`.
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  > **More free days for devs — the UI kit is ready to use.**
6
6
 
7
7
  [![Live docs](https://img.shields.io/badge/docs-live-2050d8?style=flat-square)](https://cahyo-dimas.github.io/freeday-ui-kit/)
8
- [![Release](https://img.shields.io/badge/release-v1.13.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v1.13.0)
8
+ [![Release](https://img.shields.io/badge/release-v1.14.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v1.14.0)
9
9
 
10
10
  A token-driven, framework-agnostic UI kit — one source of truth for color, typography,
11
11
  spacing, and components. Blueprint: `docs/superpowers/specs/2026-07-21-freeday-ui-kit-design.md`.
@@ -21,6 +21,8 @@ export interface FdyAutocompleteProps {
21
21
  ariaLabel?: string;
22
22
  ariaLabelledby?: string;
23
23
  disabled?: boolean;
24
+ /** Locked/view mode: the input is not editable and the list won't open, but it stays focusable and shows its value. Unlike `disabled`, it keeps tab order and isn't greyed. */
25
+ readonly?: boolean;
24
26
  invalid?: boolean;
25
27
  describedby?: string;
26
28
  }
@@ -44,6 +46,7 @@ export function FdyAutocomplete(props: FdyAutocompleteProps): JSX.Element {
44
46
  }, []);
45
47
 
46
48
  const isDisabled: boolean = props.disabled === true;
49
+ const isReadonly: boolean = props.readonly === true;
47
50
  const isInvalid: boolean = props.invalid === true;
48
51
 
49
52
  const filtered: string[] = useMemo((): string[] => {
@@ -54,7 +57,7 @@ export function FdyAutocomplete(props: FdyAutocompleteProps): JSX.Element {
54
57
  const activeDescendant: string | undefined =
55
58
  open && active >= 0 && active < filtered.length ? optionId(active) : undefined;
56
59
 
57
- const openList = (): void => { if (!isDisabled) setOpen(true); };
60
+ const openList = (): void => { if (!isDisabled && !isReadonly) setOpen(true); };
58
61
  const closeList = (): void => { setOpen(false); setActive(-1); };
59
62
 
60
63
  const choose = (label: string): void => {
@@ -65,7 +68,7 @@ export function FdyAutocomplete(props: FdyAutocompleteProps): JSX.Element {
65
68
  };
66
69
 
67
70
  const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>): void => {
68
- if (isDisabled) return;
71
+ if (isDisabled || isReadonly) return;
69
72
  switch (e.key) {
70
73
  case 'ArrowDown':
71
74
  e.preventDefault();
@@ -128,6 +131,7 @@ export function FdyAutocomplete(props: FdyAutocompleteProps): JSX.Element {
128
131
  autoComplete="off"
129
132
  placeholder={props.placeholder}
130
133
  disabled={isDisabled}
134
+ readOnly={isReadonly}
131
135
  value={props.value}
132
136
  onChange={(e: React.ChangeEvent<HTMLInputElement>): void => { props.onChange(e.target.value); setActive(-1); openList(); }}
133
137
  onFocus={openList}
@@ -29,6 +29,8 @@ export interface FdyCascadeProps {
29
29
  id?: string;
30
30
  ariaLabelledby?: string;
31
31
  disabled?: boolean;
32
+ /** Locked/view mode: stays focusable and shows its value, but can't be opened or changed. Unlike `disabled`, it keeps tab order and isn't greyed. */
33
+ readonly?: boolean;
32
34
  invalid?: boolean;
33
35
  describedby?: string;
34
36
  }
@@ -70,6 +72,7 @@ export function FdyCascade(props: FdyCascadeProps): JSX.Element {
70
72
  }, []);
71
73
 
72
74
  const isDisabled: boolean = props.disabled === true;
75
+ const isReadonly: boolean = props.readonly === true;
73
76
  const isInvalid: boolean = props.invalid === true;
74
77
 
75
78
  const current: ReadonlyArray<CascadeNode> =
@@ -86,7 +89,7 @@ export function FdyCascade(props: FdyCascadeProps): JSX.Element {
86
89
  const crumb: string = stack.length > 0 ? stack.map((n: CascadeNode): string => n.label).join(sep) : name;
87
90
 
88
91
  const openPanel = (): void => {
89
- if (isDisabled) return;
92
+ if (isDisabled || isReadonly) return;
90
93
  // Re-open at the selected leaf's level for quick re-selection (matches the enhancer).
91
94
  setStack(selectedTrail !== null && selectedTrail.length > 1 ? selectedTrail.slice(0, -1) : []);
92
95
  setActive(0);
@@ -174,6 +177,7 @@ export function FdyCascade(props: FdyCascadeProps): JSX.Element {
174
177
  aria-label={props.ariaLabelledby === undefined ? name : undefined}
175
178
  aria-labelledby={props.ariaLabelledby}
176
179
  aria-invalid={isInvalid ? 'true' : undefined}
180
+ aria-readonly={isReadonly ? 'true' : undefined}
177
181
  aria-describedby={props.describedby}
178
182
  disabled={isDisabled}
179
183
  onClick={(): void => { if (open) closePanel(true); else openPanel(); }}
@@ -31,6 +31,8 @@ export interface FdyCflProps<Row extends Record<string, unknown>> {
31
31
  pageSize?: number;
32
32
  placeholder?: string;
33
33
  disabled?: boolean;
34
+ /** Locked/view mode: shows the picked value (focusable, copyable), but the search dialog can't be opened. Unlike `disabled`, it keeps tab order and isn't greyed. */
35
+ readonly?: boolean;
34
36
  invalid?: boolean;
35
37
  describedby?: string;
36
38
  id?: string;
@@ -68,6 +70,7 @@ export function FdyCfl<Row extends Record<string, unknown>>(props: FdyCflProps<R
68
70
  const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
69
71
 
70
72
  const isDisabled: boolean = props.disabled === true;
73
+ const isReadonly: boolean = props.readonly === true;
71
74
  const isInvalid: boolean = props.invalid === true;
72
75
  const displayValue: string = props.value !== null ? props.display(props.value) : '';
73
76
  // The results <table> (owner of `resultsId`) only renders in the rows branch, so gate the
@@ -187,7 +190,7 @@ export function FdyCfl<Row extends Record<string, unknown>>(props: FdyCflProps<R
187
190
 
188
191
  // --- Open / close ------------------------------------------------------------------------
189
192
  function openDialog(): void {
190
- if (isDisabled) return;
193
+ if (isDisabled || isReadonly) return;
191
194
  if (searchTimerRef.current !== null) {
192
195
  clearTimeout(searchTimerRef.current);
193
196
  searchTimerRef.current = null;
@@ -257,7 +260,7 @@ export function FdyCfl<Row extends Record<string, unknown>>(props: FdyCflProps<R
257
260
  aria-haspopup="dialog"
258
261
  aria-labelledby={props.ariaLabelledby}
259
262
  aria-label={props.ariaLabelledby ? undefined : 'Buka pencarian'}
260
- disabled={isDisabled}
263
+ disabled={isDisabled || isReadonly}
261
264
  onClick={openDialog}
262
265
  >
263
266
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
@@ -15,6 +15,8 @@ export interface FdyComboProps<T extends string> {
15
15
  ariaLabelledby?: string;
16
16
  placeholder?: string;
17
17
  disabled?: boolean;
18
+ /** Locked/view mode: stays focusable and shows its value, but can't be opened or changed. Unlike `disabled`, it keeps tab order and isn't greyed. */
19
+ readonly?: boolean;
18
20
  invalid?: boolean;
19
21
  describedby?: string;
20
22
  }
@@ -39,6 +41,7 @@ export function FdyCombo<T extends string>(props: FdyComboProps<T>): JSX.Element
39
41
  }, []);
40
42
 
41
43
  const isDisabled: boolean = props.disabled === true;
44
+ const isReadonly: boolean = props.readonly === true;
42
45
  const isInvalid: boolean = props.invalid === true;
43
46
  const selectedIndex: number = useMemo(
44
47
  (): number => props.options.findIndex((o: FdyComboOption<T>): boolean => o.value === props.value),
@@ -53,7 +56,7 @@ export function FdyCombo<T extends string>(props: FdyComboProps<T>): JSX.Element
53
56
  setHighlighted(len === 0 ? -1 : ((index % len) + len) % len);
54
57
  };
55
58
  const openList = (): void => {
56
- if (isDisabled || open) return;
59
+ if (isDisabled || isReadonly || open) return;
57
60
  setOpen(true);
58
61
  const start: number = selectedIndex >= 0 ? selectedIndex : 0;
59
62
  const len: number = props.options.length;
@@ -85,7 +88,7 @@ export function FdyCombo<T extends string>(props: FdyComboProps<T>): JSX.Element
85
88
  };
86
89
 
87
90
  const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>): void => {
88
- if (isDisabled) return;
91
+ if (isDisabled || isReadonly) return;
89
92
  switch (e.key) {
90
93
  case 'ArrowDown': e.preventDefault(); if (open) setHighlight(highlighted + 1); else openList(); break;
91
94
  case 'ArrowUp': e.preventDefault(); if (open) setHighlight(highlighted - 1); else openList(); break;
@@ -140,6 +143,7 @@ export function FdyCombo<T extends string>(props: FdyComboProps<T>): JSX.Element
140
143
  aria-activedescendant={activeDescendant}
141
144
  aria-labelledby={props.ariaLabelledby}
142
145
  aria-invalid={isInvalid ? 'true' : undefined}
146
+ aria-readonly={isReadonly ? 'true' : undefined}
143
147
  aria-describedby={props.describedby}
144
148
  disabled={isDisabled}
145
149
  onClick={toggle}
@@ -148,12 +152,16 @@ export function FdyCombo<T extends string>(props: FdyComboProps<T>): JSX.Element
148
152
  </button>
149
153
  <ul id={listboxId} ref={listboxRef} className="fdy-combo__listbox" role="listbox" hidden={!open}>
150
154
  {props.options.map((opt: FdyComboOption<T>, i: number): JSX.Element => (
155
+ // onMouseDown preventDefault keeps focus on the button: the option <li> isn't focusable, so a
156
+ // plain mousedown moves focus out of the combo, fires onBlur (focusout), and closes the list
157
+ // before the click lands. Same pattern as FdyDatepicker/FdyAutocomplete.
151
158
  <li
152
159
  id={optionId(i)}
153
160
  key={opt.value}
154
161
  className={i === highlighted ? 'fdy-combo__option is-highlighted' : 'fdy-combo__option'}
155
162
  role="option"
156
163
  aria-selected={opt.value === props.value}
164
+ onMouseDown={(e: React.MouseEvent): void => e.preventDefault()}
157
165
  onClick={(): void => choose(i)}
158
166
  onMouseMove={(): void => setHighlight(i)}
159
167
  >
@@ -19,6 +19,8 @@ export interface FdyDateRangeProps {
19
19
  max?: string;
20
20
  locale?: string;
21
21
  disabled?: boolean;
22
+ /** Locked/view mode for both pickers — focusable, values shown, but can't be opened or changed. */
23
+ readonly?: boolean;
22
24
  invalid?: boolean;
23
25
  describedby?: string;
24
26
  startPlaceholder?: string;
@@ -45,6 +47,7 @@ export function FdyDateRange(props: FdyDateRangeProps): JSX.Element {
45
47
  max={props.value.end ?? props.max}
46
48
  locale={props.locale}
47
49
  disabled={props.disabled}
50
+ readonly={props.readonly}
48
51
  invalid={props.invalid}
49
52
  describedby={props.describedby}
50
53
  placeholder={props.startPlaceholder ?? 'Dari'}
@@ -57,6 +60,7 @@ export function FdyDateRange(props: FdyDateRangeProps): JSX.Element {
57
60
  max={props.max}
58
61
  locale={props.locale}
59
62
  disabled={props.disabled}
63
+ readonly={props.readonly}
60
64
  invalid={props.invalid}
61
65
  describedby={props.describedby}
62
66
  placeholder={props.endPlaceholder ?? 'Sampai'}
@@ -29,6 +29,8 @@ export interface FdyDatepickerProps {
29
29
  locale?: string;
30
30
  placeholder?: string;
31
31
  disabled?: boolean;
32
+ /** Locked/view mode: stays focusable and shows its date, but can't be opened, cleared, or changed. Unlike `disabled`, it keeps tab order and isn't greyed. */
33
+ readonly?: boolean;
32
34
  invalid?: boolean;
33
35
  describedby?: string;
34
36
  id?: string;
@@ -116,9 +118,10 @@ export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
116
118
  const minDate: Date | null = useMemo((): Date | null => parseISO(props.min), [props.min]);
117
119
  const maxDate: Date | null = useMemo((): Date | null => parseISO(props.max), [props.max]);
118
120
  const isDisabled: boolean = props.disabled === true;
121
+ const isReadonly: boolean = props.readonly === true;
119
122
  const isInvalid: boolean = props.invalid === true;
120
123
  const displayPlaceholder: string = props.placeholder ?? 'Select date';
121
- const showClear: boolean = props.clearable === true && selectedDate !== null && !isDisabled;
124
+ const showClear: boolean = props.clearable === true && selectedDate !== null && !isDisabled && !isReadonly;
122
125
  const prevMonthLabelText: string = props.prevMonthLabel ?? 'Previous month';
123
126
  const nextMonthLabelText: string = props.nextMonthLabel ?? 'Next month';
124
127
  const clearLabelText: string = props.clearLabel ?? 'Clear date';
@@ -223,7 +226,7 @@ export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
223
226
  }
224
227
 
225
228
  function openPanel(): void {
226
- if (isDisabled || open) return;
229
+ if (isDisabled || isReadonly || open) return;
227
230
  const next: Date = selectedDate ?? focusDate ?? new Date();
228
231
  setFocusDate(next);
229
232
  setViewMonth(new Date(next.getFullYear(), next.getMonth(), 1));
@@ -319,6 +322,7 @@ export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
319
322
  aria-expanded={open}
320
323
  aria-labelledby={props.ariaLabelledby}
321
324
  aria-invalid={isInvalid ? 'true' : undefined}
325
+ aria-readonly={isReadonly ? 'true' : undefined}
322
326
  aria-describedby={props.describedby}
323
327
  disabled={isDisabled}
324
328
  onClick={toggle}
@@ -18,6 +18,8 @@ const props = defineProps<{
18
18
  ariaLabel?: string;
19
19
  ariaLabelledby?: string;
20
20
  disabled?: boolean;
21
+ /** Locked/view mode: the input is not editable and the list won't open, but it stays focusable and shows its value. Unlike `disabled`, it keeps tab order and isn't greyed. */
22
+ readonly?: boolean;
21
23
  invalid?: boolean;
22
24
  describedby?: string;
23
25
  }>();
@@ -44,6 +46,7 @@ const active: Ref<number> = ref(-1);
44
46
  usePopover(listboxEl, inputEl, open);
45
47
 
46
48
  const isDisabled: ComputedRef<boolean> = computed((): boolean => props.disabled === true);
49
+ const isReadonly: ComputedRef<boolean> = computed((): boolean => props.readonly === true);
47
50
  const isInvalid: ComputedRef<boolean> = computed((): boolean => props.invalid === true);
48
51
 
49
52
  const filtered: ComputedRef<string[]> = computed((): string[] => {
@@ -56,7 +59,7 @@ const activeDescendant: ComputedRef<string | undefined> = computed((): string |
56
59
  );
57
60
 
58
61
  function openList(): void {
59
- if (!isDisabled.value) open.value = true;
62
+ if (!isDisabled.value && !isReadonly.value) open.value = true;
60
63
  }
61
64
  function closeList(): void {
62
65
  open.value = false;
@@ -76,7 +79,7 @@ function onInput(e: Event): void {
76
79
  }
77
80
 
78
81
  function onKeydown(e: KeyboardEvent): void {
79
- if (isDisabled.value) return;
82
+ if (isDisabled.value || isReadonly.value) return;
80
83
  const len: number = filtered.value.length;
81
84
  switch (e.key) {
82
85
  case 'ArrowDown':
@@ -144,6 +147,7 @@ watch(active, async (i: number): Promise<void> => {
144
147
  autocomplete="off"
145
148
  :placeholder="placeholder"
146
149
  :disabled="isDisabled"
150
+ :readonly="isReadonly"
147
151
  :value="modelValue"
148
152
  @input="onInput"
149
153
  @focus="openList"
@@ -42,6 +42,8 @@ const props = defineProps<{
42
42
  id?: string;
43
43
  ariaLabelledby?: string;
44
44
  disabled?: boolean;
45
+ /** Locked/view mode: stays focusable and shows its value, but can't be opened or changed. Unlike `disabled`, it keeps tab order and isn't greyed. */
46
+ readonly?: boolean;
45
47
  invalid?: boolean;
46
48
  describedby?: string;
47
49
  }>();
@@ -73,6 +75,7 @@ usePopover(panelEl, triggerEl, open);
73
75
  const sep: ComputedRef<string> = computed((): string => props.separator ?? ' / ');
74
76
  const name: ComputedRef<string> = computed((): string => props.label ?? 'Pilih');
75
77
  const isDisabled: ComputedRef<boolean> = computed((): boolean => props.disabled === true);
78
+ const isReadonly: ComputedRef<boolean> = computed((): boolean => props.readonly === true);
76
79
  const isInvalid: ComputedRef<boolean> = computed((): boolean => props.invalid === true);
77
80
 
78
81
  const current: ComputedRef<ReadonlyArray<CascadeNode>> = computed((): ReadonlyArray<CascadeNode> =>
@@ -95,7 +98,7 @@ const activeDescendant: ComputedRef<string | undefined> = computed((): string |
95
98
  );
96
99
 
97
100
  function openPanel(): void {
98
- if (isDisabled.value) return;
101
+ if (isDisabled.value || isReadonly.value) return;
99
102
  // Re-open at the selected leaf's level for quick re-selection (matches the enhancer).
100
103
  const trail: CascadeNode[] | null = selectedTrail.value;
101
104
  stack.value = trail !== null && trail.length > 1 ? trail.slice(0, -1) : [];
@@ -190,6 +193,7 @@ onBeforeUnmount((): void => {
190
193
  :aria-label="ariaLabelledby === undefined ? name : undefined"
191
194
  :aria-labelledby="ariaLabelledby"
192
195
  :aria-invalid="isInvalid ? 'true' : undefined"
196
+ :aria-readonly="isReadonly ? 'true' : undefined"
193
197
  :aria-describedby="describedby"
194
198
  :disabled="isDisabled"
195
199
  @click="open ? closePanel(true) : openPanel()"
@@ -32,6 +32,8 @@ const props = defineProps<{
32
32
  pageSize?: number;
33
33
  placeholder?: string;
34
34
  disabled?: boolean;
35
+ /** Locked/view mode: shows the picked value (focusable, copyable), but the search dialog can't be opened. Unlike `disabled`, it keeps tab order and isn't greyed. */
36
+ readonly?: boolean;
35
37
  invalid?: boolean;
36
38
  describedby?: string;
37
39
  id?: string;
@@ -64,6 +66,7 @@ const error: Ref<Error | null> = ref(null);
64
66
  const activeIndex: Ref<number> = ref(-1);
65
67
 
66
68
  const isDisabled: ComputedRef<boolean> = computed((): boolean => props.disabled === true);
69
+ const isReadonly: ComputedRef<boolean> = computed((): boolean => props.readonly === true);
67
70
  const isInvalid: ComputedRef<boolean> = computed((): boolean => props.invalid === true);
68
71
  const displayValue: ComputedRef<string> = computed((): string =>
69
72
  props.modelValue !== null ? props.display(props.modelValue) : '',
@@ -194,7 +197,7 @@ function onKeydown(e: KeyboardEvent): void {
194
197
 
195
198
  // --- Open / close ---------------------------------------------------------
196
199
  function openDialog(): void {
197
- if (isDisabled.value) return;
200
+ if (isDisabled.value || isReadonly.value) return;
198
201
  if (searchTimer !== null) {
199
202
  clearTimeout(searchTimer);
200
203
  searchTimer = null;
@@ -260,7 +263,7 @@ onBeforeUnmount((): void => {
260
263
  aria-haspopup="dialog"
261
264
  :aria-labelledby="ariaLabelledby"
262
265
  :aria-label="ariaLabelledby ? undefined : 'Buka pencarian'"
263
- :disabled="isDisabled"
266
+ :disabled="isDisabled || isReadonly"
264
267
  @click="openDialog"
265
268
  >
266
269
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
@@ -22,6 +22,8 @@ const props = defineProps<{
22
22
  ariaLabelledby?: string;
23
23
  placeholder?: string;
24
24
  disabled?: boolean;
25
+ /** Locked/view mode: stays focusable and shows its value, but can't be opened or changed. Unlike `disabled`, it keeps tab order and isn't greyed. */
26
+ readonly?: boolean;
25
27
  invalid?: boolean;
26
28
  describedby?: string;
27
29
  }>();
@@ -57,6 +59,7 @@ const selectedLabel: ComputedRef<string> = computed((): string => {
57
59
  });
58
60
  const isPlaceholder: ComputedRef<boolean> = computed((): boolean => selectedIndex.value < 0);
59
61
  const isDisabled: ComputedRef<boolean> = computed((): boolean => props.disabled === true);
62
+ const isReadonly: ComputedRef<boolean> = computed((): boolean => props.readonly === true);
60
63
  const isInvalid: ComputedRef<boolean> = computed((): boolean => props.invalid === true);
61
64
  const activeDescendant: ComputedRef<string | undefined> = computed((): string | undefined =>
62
65
  open.value && highlighted.value >= 0 ? optionId(highlighted.value) : undefined,
@@ -68,7 +71,7 @@ function setHighlight(index: number): void {
68
71
  }
69
72
 
70
73
  function openList(): void {
71
- if (isDisabled.value || open.value) return;
74
+ if (isDisabled.value || isReadonly.value || open.value) return;
72
75
  open.value = true;
73
76
  setHighlight(selectedIndex.value >= 0 ? selectedIndex.value : 0);
74
77
  }
@@ -114,7 +117,7 @@ function typeahead(char: string): void {
114
117
  }
115
118
 
116
119
  function onKeydown(e: KeyboardEvent): void {
117
- if (isDisabled.value) return;
120
+ if (isDisabled.value || isReadonly.value) return;
118
121
  switch (e.key) {
119
122
  case 'ArrowDown':
120
123
  e.preventDefault();
@@ -207,6 +210,7 @@ onBeforeUnmount((): void => {
207
210
  :aria-activedescendant="activeDescendant"
208
211
  :aria-labelledby="ariaLabelledby"
209
212
  :aria-invalid="isInvalid ? 'true' : undefined"
213
+ :aria-readonly="isReadonly ? 'true' : undefined"
210
214
  :aria-describedby="describedby"
211
215
  :disabled="isDisabled"
212
216
  @click="toggle"
@@ -214,6 +218,9 @@ onBeforeUnmount((): void => {
214
218
  <span class="fdy-combo__value" :class="{ 'fdy-combo__value--placeholder': isPlaceholder }">{{ selectedLabel }}</span>
215
219
  </button>
216
220
  <ul :id="listboxId" ref="listboxEl" class="fdy-combo__listbox" role="listbox" popover="manual" :hidden="!open">
221
+ <!-- @mousedown.prevent keeps focus on the button: the option <li> isn't focusable, so a plain
222
+ mousedown moves focus out of the combo, fires @focusout, and closes the list before the click
223
+ lands — mouse-select would silently do nothing. Same pattern as FdyDatepicker/FdyAutocomplete. -->
217
224
  <li
218
225
  v-for="(opt, i) in options"
219
226
  :id="optionId(i)"
@@ -222,6 +229,7 @@ onBeforeUnmount((): void => {
222
229
  :class="{ 'is-highlighted': i === highlighted }"
223
230
  role="option"
224
231
  :aria-selected="opt.value === modelValue"
232
+ @mousedown.prevent
225
233
  @click="choose(i)"
226
234
  @mousemove="setHighlight(i)"
227
235
  >
@@ -21,6 +21,8 @@ const props = defineProps<{
21
21
  max?: string;
22
22
  locale?: string;
23
23
  disabled?: boolean;
24
+ /** Locked/view mode for both pickers — focusable, values shown, but can't be opened or changed. */
25
+ readonly?: boolean;
24
26
  invalid?: boolean;
25
27
  describedby?: string;
26
28
  startPlaceholder?: string;
@@ -57,6 +59,7 @@ function setEnd(end: string): void {
57
59
  :max="startMax"
58
60
  :locale="locale"
59
61
  :disabled="disabled"
62
+ :readonly="readonly"
60
63
  :invalid="invalid"
61
64
  :describedby="describedby"
62
65
  :placeholder="startPlaceholder ?? 'Dari'"
@@ -69,6 +72,7 @@ function setEnd(end: string): void {
69
72
  :max="max"
70
73
  :locale="locale"
71
74
  :disabled="disabled"
75
+ :readonly="readonly"
72
76
  :invalid="invalid"
73
77
  :describedby="describedby"
74
78
  :placeholder="endPlaceholder ?? 'Sampai'"
@@ -31,6 +31,8 @@ const props = defineProps<{
31
31
  locale?: string;
32
32
  placeholder?: string;
33
33
  disabled?: boolean;
34
+ /** Locked/view mode: stays focusable and shows its date, but can't be opened, cleared, or changed. Unlike `disabled`, it keeps tab order and isn't greyed. */
35
+ readonly?: boolean;
34
36
  invalid?: boolean;
35
37
  describedby?: string;
36
38
  id?: string;
@@ -105,10 +107,11 @@ const selectedDate: ComputedRef<Date | null> = computed((): Date | null => parse
105
107
  const minDate: ComputedRef<Date | null> = computed((): Date | null => parseISO(props.min));
106
108
  const maxDate: ComputedRef<Date | null> = computed((): Date | null => parseISO(props.max));
107
109
  const isDisabled: ComputedRef<boolean> = computed((): boolean => props.disabled === true);
110
+ const isReadonly: ComputedRef<boolean> = computed((): boolean => props.readonly === true);
108
111
  const isInvalid: ComputedRef<boolean> = computed((): boolean => props.invalid === true);
109
112
  const displayPlaceholder: ComputedRef<string> = computed((): string => props.placeholder ?? 'Select date');
110
113
  const showClear: ComputedRef<boolean> = computed(
111
- (): boolean => props.clearable === true && selectedDate.value !== null && isDisabled.value === false,
114
+ (): boolean => props.clearable === true && selectedDate.value !== null && isDisabled.value === false && isReadonly.value === false,
112
115
  );
113
116
  const prevMonthLabelText: ComputedRef<string> = computed((): string => props.prevMonthLabel ?? 'Previous month');
114
117
  const nextMonthLabelText: ComputedRef<string> = computed((): string => props.nextMonthLabel ?? 'Next month');
@@ -211,7 +214,7 @@ function clearValue(): void {
211
214
  }
212
215
 
213
216
  function openPanel(): void {
214
- if (isDisabled.value || open.value) return;
217
+ if (isDisabled.value || isReadonly.value || open.value) return;
215
218
  focusDate.value = selectedDate.value ?? focusDate.value ?? new Date();
216
219
  view.value = new Date(focusDate.value.getFullYear(), focusDate.value.getMonth(), 1);
217
220
  open.value = true;
@@ -314,6 +317,7 @@ onBeforeUnmount((): void => {
314
317
  :aria-expanded="open"
315
318
  :aria-labelledby="ariaLabelledby"
316
319
  :aria-invalid="isInvalid ? 'true' : undefined"
320
+ :aria-readonly="isReadonly ? 'true' : undefined"
317
321
  :aria-describedby="describedby"
318
322
  :disabled="isDisabled"
319
323
  @click="toggle"
@@ -9,6 +9,8 @@
9
9
  * Markup: <div data-fdy-datetimepicker>
10
10
  * <div data-fdy-datepicker data-value="2026-07-21"></div>
11
11
  * <div data-fdy-timepicker data-value="14:30"></div></div>
12
+ * State: add data-fdy-disabled or data-fdy-invalid on the wrapper to reflect it onto both
13
+ * child triggers as one control (read-only is adapter-only — a vanilla datetime is interactive).
12
14
  */
13
15
  (function () {
14
16
  'use strict';
@@ -22,6 +24,28 @@
22
24
  var tp = wrap.querySelector('[data-fdy-timepicker]');
23
25
  if (!dp || !tp) return;
24
26
 
27
+ // Reflect one wrapper-level state onto BOTH child triggers so a datetime reads as a single
28
+ // disabled/invalid control. Deferred with setTimeout(0) — NOT a microtask: the timepicker
29
+ // enhancer registers its DOMContentLoaded init AFTER this composer, and microtasks drain
30
+ // *between* listeners (so a microtask would run before the timepicker trigger exists). A
31
+ // macrotask waits until the whole init pass is done and both triggers are built. (readonly is
32
+ // intentionally not supported here: a vanilla datetime is interactive; adapters cover read-only.)
33
+ if (wrap.hasAttribute('data-fdy-disabled') || wrap.hasAttribute('data-fdy-invalid')) {
34
+ var applyState = function () {
35
+ var dpTrig = dp.querySelector('.fdy-datepicker__trigger');
36
+ var tpTrig = tp.querySelector('.fdy-timepicker__trigger');
37
+ if (wrap.hasAttribute('data-fdy-disabled')) {
38
+ if (dpTrig) dpTrig.disabled = true;
39
+ if (tpTrig) tpTrig.disabled = true;
40
+ }
41
+ if (wrap.hasAttribute('data-fdy-invalid')) {
42
+ if (dpTrig) dpTrig.setAttribute('aria-invalid', 'true');
43
+ if (tpTrig) tpTrig.setAttribute('aria-invalid', 'true');
44
+ }
45
+ };
46
+ setTimeout(applyState, 0);
47
+ }
48
+
25
49
  var date = dp.getAttribute('data-value') || '';
26
50
  var time = tp.getAttribute('data-value') || '';
27
51
 
@@ -678,6 +678,9 @@ a { color: var(--color-primary); }
678
678
  .fdy-cascade__trigger:focus-visible{outline:none;border-color:var(--color-primary);box-shadow:inset 0 1px 2px rgba(16,14,30,.04),0 0 0 3px color-mix(in srgb,var(--color-primary) 26%,transparent);}
679
679
  .fdy-cascade__trigger.is-open{border-color:var(--color-primary);box-shadow:0 0 0 3px color-mix(in srgb,var(--color-primary) 26%,transparent);}
680
680
  .fdy-cascade__trigger:disabled,.fdy-cascade__trigger[aria-disabled="true"]{opacity:.5;cursor:not-allowed;}
681
+ .fdy-cascade__trigger[aria-readonly="true"]{background:var(--color-surface-2);border-color:var(--color-border);cursor:default;}
682
+ .fdy-cascade__trigger[aria-readonly="true"]:hover{border-color:var(--color-border);}
683
+ .fdy-cascade__trigger[aria-readonly="true"]:focus-visible{border-color:var(--color-border-strong);box-shadow:none;}
681
684
  .fdy-cascade--error .fdy-cascade__trigger,.fdy-cascade__trigger[aria-invalid="true"]{border-color:var(--color-danger);}
682
685
  .fdy-cascade--error .fdy-cascade__trigger:focus-visible,.fdy-cascade--error .fdy-cascade__trigger.is-open,.fdy-cascade__trigger[aria-invalid="true"]:focus-visible{border-color:var(--color-danger);box-shadow:0 0 0 3px color-mix(in srgb,var(--color-danger) 26%,transparent);}
683
686
  .fdy-cascade__value{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
@@ -814,6 +817,11 @@ a { color: var(--color-primary); }
814
817
  .fdy-combo__button:focus-visible{outline:none;border-color:var(--color-primary);box-shadow:inset 0 1px 2px rgba(16,14,30,.04),0 0 0 3px color-mix(in srgb,var(--color-primary) 26%,transparent);}
815
818
  .fdy-combo__button.is-open{border-color:var(--color-primary);box-shadow:0 0 0 3px color-mix(in srgb,var(--color-primary) 26%,transparent);}
816
819
  .fdy-combo__button:disabled,.fdy-combo__button[aria-disabled="true"]{opacity:.5;cursor:not-allowed;}
820
+ /* Read-only: stays focusable & shows its value at full contrast but can't open — muted surface,
821
+ softened border, default cursor, no focus-ring escalation (matches input[readonly], #39). */
822
+ .fdy-combo__button[aria-readonly="true"]{background:var(--color-surface-2);border-color:var(--color-border);cursor:default;}
823
+ .fdy-combo__button[aria-readonly="true"]:hover{border-color:var(--color-border);}
824
+ .fdy-combo__button[aria-readonly="true"]:focus-visible{border-color:var(--color-border-strong);box-shadow:none;}
817
825
  .fdy-combo--error .fdy-combo__button,.fdy-combo__button[aria-invalid="true"]{border-color:var(--color-danger);}
818
826
  .fdy-combo--error .fdy-combo__button:focus-visible,.fdy-combo--error .fdy-combo__button.is-open,.fdy-combo__button[aria-invalid="true"]:focus-visible{border-color:var(--color-danger);box-shadow:0 0 0 3px color-mix(in srgb,var(--color-danger) 26%,transparent);}
819
827
  .fdy-combo__value{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
@@ -844,6 +852,10 @@ a { color: var(--color-primary); }
844
852
  .fdy-datepicker__trigger.is-open{border-color:var(--color-primary);box-shadow:0 0 0 3px color-mix(in srgb,var(--color-primary) 26%,transparent);}
845
853
  .fdy-datepicker--error .fdy-datepicker__trigger,.fdy-datepicker__trigger[aria-invalid="true"]{border-color:var(--color-danger);}
846
854
  .fdy-datepicker--error .fdy-datepicker__trigger:focus-visible,.fdy-datepicker--error .fdy-datepicker__trigger.is-open,.fdy-datepicker__trigger[aria-invalid="true"]:focus-visible{border-color:var(--color-danger);box-shadow:0 0 0 3px color-mix(in srgb,var(--color-danger) 26%,transparent);}
855
+ .fdy-datepicker__trigger:disabled,.fdy-datepicker__trigger[aria-disabled="true"]{opacity:.5;cursor:not-allowed;}
856
+ .fdy-datepicker__trigger[aria-readonly="true"]{background:var(--color-surface-2);border-color:var(--color-border);cursor:default;}
857
+ .fdy-datepicker__trigger[aria-readonly="true"]:hover{border-color:var(--color-border);}
858
+ .fdy-datepicker__trigger[aria-readonly="true"]:focus-visible{border-color:var(--color-border-strong);box-shadow:none;}
847
859
  .fdy-datepicker__value{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
848
860
  .fdy-datepicker__value--placeholder{color:var(--color-text-subtle);}
849
861
  .fdy-datepicker__icon{flex:none;display:inline-flex;color:var(--color-text-subtle);}
@@ -943,6 +955,7 @@ a { color: var(--color-primary); }
943
955
  .fdy-dropzone:hover{border-color:var(--color-text-muted);}
944
956
  .fdy-dropzone:focus-visible{outline:none;border-color:var(--color-primary);box-shadow:0 0 0 3px color-mix(in srgb,var(--color-primary) 26%,transparent);}
945
957
  .fdy-dropzone.is-dragover{border-color:var(--color-primary);background:var(--color-primary-soft);color:var(--color-primary-strong);}
958
+ .fdy-dropzone[aria-disabled="true"],.fdy-dropzone.is-disabled{opacity:.55;cursor:not-allowed;pointer-events:none;}
946
959
  .fdy-dropzone__icon{color:var(--color-text-subtle);}
947
960
  .fdy-dropzone.is-dragover .fdy-dropzone__icon{color:var(--color-primary);}
948
961
  .fdy-dropzone__icon svg{display:block;width:2rem;height:2rem;}
@@ -1058,6 +1071,12 @@ fieldset.fdy-field>legend{padding:0;float:none;}
1058
1071
  .fdy-input:hover,.fdy-textarea:hover{border-color:var(--color-text-muted);}
1059
1072
  .fdy-input:focus,.fdy-textarea:focus{outline:none;border-color:var(--color-primary);box-shadow:inset 0 1px 2px rgba(16,14,30,.04),0 0 0 3px color-mix(in srgb,var(--color-primary) 26%,transparent);}
1060
1073
  .fdy-input:disabled,.fdy-textarea:disabled,.fdy-input[aria-disabled="true"],.fdy-textarea[aria-disabled="true"]{opacity:.5;cursor:not-allowed;}
1074
+ /* Read-only (#39): the value is real and may be read/copied, only not edited. Full contrast (unlike
1075
+ :disabled which dims to placeholder-grey), muted surface + softened border so it still reads as
1076
+ inert, default cursor, no focus-ring escalation. */
1077
+ .fdy-input[readonly],.fdy-textarea[readonly]{background:var(--color-surface-2);border-color:var(--color-border);color:var(--color-text);cursor:default;}
1078
+ .fdy-input[readonly]:hover,.fdy-textarea[readonly]:hover{border-color:var(--color-border);}
1079
+ .fdy-input[readonly]:focus-visible,.fdy-textarea[readonly]:focus-visible{border-color:var(--color-border-strong);box-shadow:none;}
1061
1080
  .fdy-input--error,.fdy-input[aria-invalid="true"],.fdy-textarea[aria-invalid="true"]{border-color:var(--color-danger);}
1062
1081
  .fdy-input--error:focus,.fdy-input[aria-invalid="true"]:focus,.fdy-textarea[aria-invalid="true"]:focus{border-color:var(--color-danger);box-shadow:0 0 0 3px color-mix(in srgb,var(--color-danger) 26%,transparent);}
1063
1082
  .fdy-help{font-size:var(--text-xs);color:var(--color-text-muted);}
@@ -1144,6 +1163,12 @@ fieldset.fdy-field>legend{padding:0;float:none;}
1144
1163
  .fdy-rating__star.is-filled svg{fill:var(--color-warning);stroke:var(--color-warning);}
1145
1164
  .fdy-rating__star input:focus-visible + svg{outline:2px solid var(--color-primary);outline-offset:2px;border-radius:var(--radius-xs);}
1146
1165
  .fdy-rating--readonly .fdy-rating__star{cursor:default;}
1166
+ /* Disabled: not editable and greyed (readonly stays full-strength; disabled dims). */
1167
+ .fdy-rating--disabled .fdy-rating__star{cursor:not-allowed;}
1168
+ .fdy-rating--disabled .fdy-rating__star svg{opacity:.5;}
1169
+ /* Invalid: tint the (outline) empty stars danger so a required-but-empty rating reads as an error,
1170
+ paired with a help/error message — never colour alone. */
1171
+ .fdy-rating--error .fdy-rating__star svg{stroke:var(--color-danger);}
1147
1172
  .fdy-rating--sm .fdy-rating__star svg{width:1.1rem;height:1.1rem;}
1148
1173
 
1149
1174
  /* Freeday — Selection controls */
@@ -1305,6 +1330,8 @@ fieldset.fdy-field>legend{padding:0;float:none;}
1305
1330
  .fdy-tabs__list{display:flex;gap:var(--space-1);border-bottom:var(--bw) solid var(--color-border);overflow-x:auto;}
1306
1331
  .fdy-tabs__tab{appearance:none;border:0;background:transparent;cursor:pointer;white-space:nowrap;font-family:var(--font-body);font-size:var(--text-sm);font-weight:var(--weight-medium);color:var(--color-text-muted);padding:var(--space-3) var(--space-4);border-bottom:2px solid transparent;margin-bottom:-1px;transition:color var(--dur-fast) var(--ease-standard),border-color var(--dur-fast) var(--ease-standard);}
1307
1332
  .fdy-tabs__tab:hover{color:var(--color-text);}
1333
+ .fdy-tabs__tab:disabled,.fdy-tabs__tab[aria-disabled="true"]{opacity:.5;cursor:not-allowed;color:var(--color-text-muted);}
1334
+ .fdy-tabs__tab:disabled:hover,.fdy-tabs__tab[aria-disabled="true"]:hover{color:var(--color-text-muted);}
1308
1335
  .fdy-tabs__tab[aria-selected="true"]{color:var(--color-primary-strong);border-bottom-color:var(--color-primary);}
1309
1336
  .fdy-tabs__tab:focus-visible{outline:none;border-radius:var(--radius-sm);box-shadow:0 0 0 3px color-mix(in srgb,var(--color-primary) 26%,transparent);}
1310
1337
  .fdy-tabs__panel{padding:var(--space-5) 0;}
@@ -1335,6 +1362,7 @@ fieldset.fdy-field>legend{padding:0;float:none;}
1335
1362
  .fdy-timepicker__trigger.is-open{border-color:var(--color-primary);box-shadow:0 0 0 3px color-mix(in srgb,var(--color-primary) 26%,transparent);}
1336
1363
  .fdy-timepicker--error .fdy-timepicker__trigger,.fdy-timepicker__trigger[aria-invalid="true"]{border-color:var(--color-danger);}
1337
1364
  .fdy-timepicker--error .fdy-timepicker__trigger:focus-visible,.fdy-timepicker--error .fdy-timepicker__trigger.is-open,.fdy-timepicker__trigger[aria-invalid="true"]:focus-visible{border-color:var(--color-danger);box-shadow:0 0 0 3px color-mix(in srgb,var(--color-danger) 26%,transparent);}
1365
+ .fdy-timepicker__trigger:disabled,.fdy-timepicker__trigger[aria-disabled="true"]{opacity:.5;cursor:not-allowed;}
1338
1366
  .fdy-timepicker__value{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-variant-numeric:tabular-nums;}
1339
1367
  .fdy-timepicker__value--placeholder{color:var(--color-text-subtle);}
1340
1368
  .fdy-timepicker__icon{flex:none;display:inline-flex;color:var(--color-text-subtle);}
@@ -1387,6 +1415,8 @@ fieldset.fdy-field>legend{padding:0;float:none;}
1387
1415
  .fdy-tree__icon{flex:none;width:1rem;height:1rem;color:var(--color-text-muted);}
1388
1416
  .fdy-tree__leaf{padding-left:calc(var(--space-2) * 2 + .9rem);}
1389
1417
  .fdy-tree__leaf .fdy-tree__icon{color:var(--color-text-subtle);}
1418
+ /* Disabled row (branch summary or leaf): dimmed, no hover, not interactive. */
1419
+ .fdy-tree__leaf[aria-disabled="true"],.fdy-tree__branch[aria-disabled="true"] > summary{opacity:.5;cursor:not-allowed;pointer-events:none;}
1390
1420
 
1391
1421
  /* Checkbox variant — a selectable tree (cascading parent/child via freeday-tree.js).
1392
1422
  * A native <input class="fdy-checkbox"> sits at the start of every row; a partially
package/dist/freeday.css CHANGED
@@ -333,6 +333,9 @@ a { color: var(--color-primary); }
333
333
  .fdy-cascade__trigger:focus-visible{outline:none;border-color:var(--color-primary);box-shadow:inset 0 1px 2px rgba(16,14,30,.04),0 0 0 3px color-mix(in srgb,var(--color-primary) 26%,transparent);}
334
334
  .fdy-cascade__trigger.is-open{border-color:var(--color-primary);box-shadow:0 0 0 3px color-mix(in srgb,var(--color-primary) 26%,transparent);}
335
335
  .fdy-cascade__trigger:disabled,.fdy-cascade__trigger[aria-disabled="true"]{opacity:.5;cursor:not-allowed;}
336
+ .fdy-cascade__trigger[aria-readonly="true"]{background:var(--color-surface-2);border-color:var(--color-border);cursor:default;}
337
+ .fdy-cascade__trigger[aria-readonly="true"]:hover{border-color:var(--color-border);}
338
+ .fdy-cascade__trigger[aria-readonly="true"]:focus-visible{border-color:var(--color-border-strong);box-shadow:none;}
336
339
  .fdy-cascade--error .fdy-cascade__trigger,.fdy-cascade__trigger[aria-invalid="true"]{border-color:var(--color-danger);}
337
340
  .fdy-cascade--error .fdy-cascade__trigger:focus-visible,.fdy-cascade--error .fdy-cascade__trigger.is-open,.fdy-cascade__trigger[aria-invalid="true"]:focus-visible{border-color:var(--color-danger);box-shadow:0 0 0 3px color-mix(in srgb,var(--color-danger) 26%,transparent);}
338
341
  .fdy-cascade__value{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
@@ -469,6 +472,11 @@ a { color: var(--color-primary); }
469
472
  .fdy-combo__button:focus-visible{outline:none;border-color:var(--color-primary);box-shadow:inset 0 1px 2px rgba(16,14,30,.04),0 0 0 3px color-mix(in srgb,var(--color-primary) 26%,transparent);}
470
473
  .fdy-combo__button.is-open{border-color:var(--color-primary);box-shadow:0 0 0 3px color-mix(in srgb,var(--color-primary) 26%,transparent);}
471
474
  .fdy-combo__button:disabled,.fdy-combo__button[aria-disabled="true"]{opacity:.5;cursor:not-allowed;}
475
+ /* Read-only: stays focusable & shows its value at full contrast but can't open — muted surface,
476
+ softened border, default cursor, no focus-ring escalation (matches input[readonly], #39). */
477
+ .fdy-combo__button[aria-readonly="true"]{background:var(--color-surface-2);border-color:var(--color-border);cursor:default;}
478
+ .fdy-combo__button[aria-readonly="true"]:hover{border-color:var(--color-border);}
479
+ .fdy-combo__button[aria-readonly="true"]:focus-visible{border-color:var(--color-border-strong);box-shadow:none;}
472
480
  .fdy-combo--error .fdy-combo__button,.fdy-combo__button[aria-invalid="true"]{border-color:var(--color-danger);}
473
481
  .fdy-combo--error .fdy-combo__button:focus-visible,.fdy-combo--error .fdy-combo__button.is-open,.fdy-combo__button[aria-invalid="true"]:focus-visible{border-color:var(--color-danger);box-shadow:0 0 0 3px color-mix(in srgb,var(--color-danger) 26%,transparent);}
474
482
  .fdy-combo__value{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
@@ -499,6 +507,10 @@ a { color: var(--color-primary); }
499
507
  .fdy-datepicker__trigger.is-open{border-color:var(--color-primary);box-shadow:0 0 0 3px color-mix(in srgb,var(--color-primary) 26%,transparent);}
500
508
  .fdy-datepicker--error .fdy-datepicker__trigger,.fdy-datepicker__trigger[aria-invalid="true"]{border-color:var(--color-danger);}
501
509
  .fdy-datepicker--error .fdy-datepicker__trigger:focus-visible,.fdy-datepicker--error .fdy-datepicker__trigger.is-open,.fdy-datepicker__trigger[aria-invalid="true"]:focus-visible{border-color:var(--color-danger);box-shadow:0 0 0 3px color-mix(in srgb,var(--color-danger) 26%,transparent);}
510
+ .fdy-datepicker__trigger:disabled,.fdy-datepicker__trigger[aria-disabled="true"]{opacity:.5;cursor:not-allowed;}
511
+ .fdy-datepicker__trigger[aria-readonly="true"]{background:var(--color-surface-2);border-color:var(--color-border);cursor:default;}
512
+ .fdy-datepicker__trigger[aria-readonly="true"]:hover{border-color:var(--color-border);}
513
+ .fdy-datepicker__trigger[aria-readonly="true"]:focus-visible{border-color:var(--color-border-strong);box-shadow:none;}
502
514
  .fdy-datepicker__value{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
503
515
  .fdy-datepicker__value--placeholder{color:var(--color-text-subtle);}
504
516
  .fdy-datepicker__icon{flex:none;display:inline-flex;color:var(--color-text-subtle);}
@@ -598,6 +610,7 @@ a { color: var(--color-primary); }
598
610
  .fdy-dropzone:hover{border-color:var(--color-text-muted);}
599
611
  .fdy-dropzone:focus-visible{outline:none;border-color:var(--color-primary);box-shadow:0 0 0 3px color-mix(in srgb,var(--color-primary) 26%,transparent);}
600
612
  .fdy-dropzone.is-dragover{border-color:var(--color-primary);background:var(--color-primary-soft);color:var(--color-primary-strong);}
613
+ .fdy-dropzone[aria-disabled="true"],.fdy-dropzone.is-disabled{opacity:.55;cursor:not-allowed;pointer-events:none;}
601
614
  .fdy-dropzone__icon{color:var(--color-text-subtle);}
602
615
  .fdy-dropzone.is-dragover .fdy-dropzone__icon{color:var(--color-primary);}
603
616
  .fdy-dropzone__icon svg{display:block;width:2rem;height:2rem;}
@@ -713,6 +726,12 @@ fieldset.fdy-field>legend{padding:0;float:none;}
713
726
  .fdy-input:hover,.fdy-textarea:hover{border-color:var(--color-text-muted);}
714
727
  .fdy-input:focus,.fdy-textarea:focus{outline:none;border-color:var(--color-primary);box-shadow:inset 0 1px 2px rgba(16,14,30,.04),0 0 0 3px color-mix(in srgb,var(--color-primary) 26%,transparent);}
715
728
  .fdy-input:disabled,.fdy-textarea:disabled,.fdy-input[aria-disabled="true"],.fdy-textarea[aria-disabled="true"]{opacity:.5;cursor:not-allowed;}
729
+ /* Read-only (#39): the value is real and may be read/copied, only not edited. Full contrast (unlike
730
+ :disabled which dims to placeholder-grey), muted surface + softened border so it still reads as
731
+ inert, default cursor, no focus-ring escalation. */
732
+ .fdy-input[readonly],.fdy-textarea[readonly]{background:var(--color-surface-2);border-color:var(--color-border);color:var(--color-text);cursor:default;}
733
+ .fdy-input[readonly]:hover,.fdy-textarea[readonly]:hover{border-color:var(--color-border);}
734
+ .fdy-input[readonly]:focus-visible,.fdy-textarea[readonly]:focus-visible{border-color:var(--color-border-strong);box-shadow:none;}
716
735
  .fdy-input--error,.fdy-input[aria-invalid="true"],.fdy-textarea[aria-invalid="true"]{border-color:var(--color-danger);}
717
736
  .fdy-input--error:focus,.fdy-input[aria-invalid="true"]:focus,.fdy-textarea[aria-invalid="true"]:focus{border-color:var(--color-danger);box-shadow:0 0 0 3px color-mix(in srgb,var(--color-danger) 26%,transparent);}
718
737
  .fdy-help{font-size:var(--text-xs);color:var(--color-text-muted);}
@@ -799,6 +818,12 @@ fieldset.fdy-field>legend{padding:0;float:none;}
799
818
  .fdy-rating__star.is-filled svg{fill:var(--color-warning);stroke:var(--color-warning);}
800
819
  .fdy-rating__star input:focus-visible + svg{outline:2px solid var(--color-primary);outline-offset:2px;border-radius:var(--radius-xs);}
801
820
  .fdy-rating--readonly .fdy-rating__star{cursor:default;}
821
+ /* Disabled: not editable and greyed (readonly stays full-strength; disabled dims). */
822
+ .fdy-rating--disabled .fdy-rating__star{cursor:not-allowed;}
823
+ .fdy-rating--disabled .fdy-rating__star svg{opacity:.5;}
824
+ /* Invalid: tint the (outline) empty stars danger so a required-but-empty rating reads as an error,
825
+ paired with a help/error message — never colour alone. */
826
+ .fdy-rating--error .fdy-rating__star svg{stroke:var(--color-danger);}
802
827
  .fdy-rating--sm .fdy-rating__star svg{width:1.1rem;height:1.1rem;}
803
828
 
804
829
  /* Freeday — Selection controls */
@@ -960,6 +985,8 @@ fieldset.fdy-field>legend{padding:0;float:none;}
960
985
  .fdy-tabs__list{display:flex;gap:var(--space-1);border-bottom:var(--bw) solid var(--color-border);overflow-x:auto;}
961
986
  .fdy-tabs__tab{appearance:none;border:0;background:transparent;cursor:pointer;white-space:nowrap;font-family:var(--font-body);font-size:var(--text-sm);font-weight:var(--weight-medium);color:var(--color-text-muted);padding:var(--space-3) var(--space-4);border-bottom:2px solid transparent;margin-bottom:-1px;transition:color var(--dur-fast) var(--ease-standard),border-color var(--dur-fast) var(--ease-standard);}
962
987
  .fdy-tabs__tab:hover{color:var(--color-text);}
988
+ .fdy-tabs__tab:disabled,.fdy-tabs__tab[aria-disabled="true"]{opacity:.5;cursor:not-allowed;color:var(--color-text-muted);}
989
+ .fdy-tabs__tab:disabled:hover,.fdy-tabs__tab[aria-disabled="true"]:hover{color:var(--color-text-muted);}
963
990
  .fdy-tabs__tab[aria-selected="true"]{color:var(--color-primary-strong);border-bottom-color:var(--color-primary);}
964
991
  .fdy-tabs__tab:focus-visible{outline:none;border-radius:var(--radius-sm);box-shadow:0 0 0 3px color-mix(in srgb,var(--color-primary) 26%,transparent);}
965
992
  .fdy-tabs__panel{padding:var(--space-5) 0;}
@@ -990,6 +1017,7 @@ fieldset.fdy-field>legend{padding:0;float:none;}
990
1017
  .fdy-timepicker__trigger.is-open{border-color:var(--color-primary);box-shadow:0 0 0 3px color-mix(in srgb,var(--color-primary) 26%,transparent);}
991
1018
  .fdy-timepicker--error .fdy-timepicker__trigger,.fdy-timepicker__trigger[aria-invalid="true"]{border-color:var(--color-danger);}
992
1019
  .fdy-timepicker--error .fdy-timepicker__trigger:focus-visible,.fdy-timepicker--error .fdy-timepicker__trigger.is-open,.fdy-timepicker__trigger[aria-invalid="true"]:focus-visible{border-color:var(--color-danger);box-shadow:0 0 0 3px color-mix(in srgb,var(--color-danger) 26%,transparent);}
1020
+ .fdy-timepicker__trigger:disabled,.fdy-timepicker__trigger[aria-disabled="true"]{opacity:.5;cursor:not-allowed;}
993
1021
  .fdy-timepicker__value{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-variant-numeric:tabular-nums;}
994
1022
  .fdy-timepicker__value--placeholder{color:var(--color-text-subtle);}
995
1023
  .fdy-timepicker__icon{flex:none;display:inline-flex;color:var(--color-text-subtle);}
@@ -1042,6 +1070,8 @@ fieldset.fdy-field>legend{padding:0;float:none;}
1042
1070
  .fdy-tree__icon{flex:none;width:1rem;height:1rem;color:var(--color-text-muted);}
1043
1071
  .fdy-tree__leaf{padding-left:calc(var(--space-2) * 2 + .9rem);}
1044
1072
  .fdy-tree__leaf .fdy-tree__icon{color:var(--color-text-subtle);}
1073
+ /* Disabled row (branch summary or leaf): dimmed, no hover, not interactive. */
1074
+ .fdy-tree__leaf[aria-disabled="true"],.fdy-tree__branch[aria-disabled="true"] > summary{opacity:.5;cursor:not-allowed;pointer-events:none;}
1045
1075
 
1046
1076
  /* Checkbox variant — a selectable tree (cascading parent/child via freeday-tree.js).
1047
1077
  * A native <input class="fdy-checkbox"> sits at the start of every row; a partially
package/dist/freeday.js CHANGED
@@ -1618,6 +1618,8 @@
1618
1618
  * Markup: <div data-fdy-datetimepicker>
1619
1619
  * <div data-fdy-datepicker data-value="2026-07-21"></div>
1620
1620
  * <div data-fdy-timepicker data-value="14:30"></div></div>
1621
+ * State: add data-fdy-disabled or data-fdy-invalid on the wrapper to reflect it onto both
1622
+ * child triggers as one control (read-only is adapter-only — a vanilla datetime is interactive).
1621
1623
  */
1622
1624
  (function () {
1623
1625
  'use strict';
@@ -1631,6 +1633,28 @@
1631
1633
  var tp = wrap.querySelector('[data-fdy-timepicker]');
1632
1634
  if (!dp || !tp) return;
1633
1635
 
1636
+ // Reflect one wrapper-level state onto BOTH child triggers so a datetime reads as a single
1637
+ // disabled/invalid control. Deferred with setTimeout(0) — NOT a microtask: the timepicker
1638
+ // enhancer registers its DOMContentLoaded init AFTER this composer, and microtasks drain
1639
+ // *between* listeners (so a microtask would run before the timepicker trigger exists). A
1640
+ // macrotask waits until the whole init pass is done and both triggers are built. (readonly is
1641
+ // intentionally not supported here: a vanilla datetime is interactive; adapters cover read-only.)
1642
+ if (wrap.hasAttribute('data-fdy-disabled') || wrap.hasAttribute('data-fdy-invalid')) {
1643
+ var applyState = function () {
1644
+ var dpTrig = dp.querySelector('.fdy-datepicker__trigger');
1645
+ var tpTrig = tp.querySelector('.fdy-timepicker__trigger');
1646
+ if (wrap.hasAttribute('data-fdy-disabled')) {
1647
+ if (dpTrig) dpTrig.disabled = true;
1648
+ if (tpTrig) tpTrig.disabled = true;
1649
+ }
1650
+ if (wrap.hasAttribute('data-fdy-invalid')) {
1651
+ if (dpTrig) dpTrig.setAttribute('aria-invalid', 'true');
1652
+ if (tpTrig) tpTrig.setAttribute('aria-invalid', 'true');
1653
+ }
1654
+ };
1655
+ setTimeout(applyState, 0);
1656
+ }
1657
+
1634
1658
  var date = dp.getAttribute('data-value') || '';
1635
1659
  var time = tp.getAttribute('data-value') || '';
1636
1660
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cahyo-dimas/freeday",
3
- "version": "1.13.0",
3
+ "version": "1.14.0",
4
4
  "description": "Freeday — token-driven, framework-agnostic UI KIT (design source-of-truth).",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -10,6 +10,9 @@
10
10
  .fdy-cascade__trigger:focus-visible{outline:none;border-color:var(--color-primary);box-shadow:inset 0 1px 2px rgba(16,14,30,.04),0 0 0 3px color-mix(in srgb,var(--color-primary) 26%,transparent);}
11
11
  .fdy-cascade__trigger.is-open{border-color:var(--color-primary);box-shadow:0 0 0 3px color-mix(in srgb,var(--color-primary) 26%,transparent);}
12
12
  .fdy-cascade__trigger:disabled,.fdy-cascade__trigger[aria-disabled="true"]{opacity:.5;cursor:not-allowed;}
13
+ .fdy-cascade__trigger[aria-readonly="true"]{background:var(--color-surface-2);border-color:var(--color-border);cursor:default;}
14
+ .fdy-cascade__trigger[aria-readonly="true"]:hover{border-color:var(--color-border);}
15
+ .fdy-cascade__trigger[aria-readonly="true"]:focus-visible{border-color:var(--color-border-strong);box-shadow:none;}
13
16
  .fdy-cascade--error .fdy-cascade__trigger,.fdy-cascade__trigger[aria-invalid="true"]{border-color:var(--color-danger);}
14
17
  .fdy-cascade--error .fdy-cascade__trigger:focus-visible,.fdy-cascade--error .fdy-cascade__trigger.is-open,.fdy-cascade__trigger[aria-invalid="true"]:focus-visible{border-color:var(--color-danger);box-shadow:0 0 0 3px color-mix(in srgb,var(--color-danger) 26%,transparent);}
15
18
  .fdy-cascade__value{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
@@ -5,6 +5,11 @@
5
5
  .fdy-combo__button:focus-visible{outline:none;border-color:var(--color-primary);box-shadow:inset 0 1px 2px rgba(16,14,30,.04),0 0 0 3px color-mix(in srgb,var(--color-primary) 26%,transparent);}
6
6
  .fdy-combo__button.is-open{border-color:var(--color-primary);box-shadow:0 0 0 3px color-mix(in srgb,var(--color-primary) 26%,transparent);}
7
7
  .fdy-combo__button:disabled,.fdy-combo__button[aria-disabled="true"]{opacity:.5;cursor:not-allowed;}
8
+ /* Read-only: stays focusable & shows its value at full contrast but can't open — muted surface,
9
+ softened border, default cursor, no focus-ring escalation (matches input[readonly], #39). */
10
+ .fdy-combo__button[aria-readonly="true"]{background:var(--color-surface-2);border-color:var(--color-border);cursor:default;}
11
+ .fdy-combo__button[aria-readonly="true"]:hover{border-color:var(--color-border);}
12
+ .fdy-combo__button[aria-readonly="true"]:focus-visible{border-color:var(--color-border-strong);box-shadow:none;}
8
13
  .fdy-combo--error .fdy-combo__button,.fdy-combo__button[aria-invalid="true"]{border-color:var(--color-danger);}
9
14
  .fdy-combo--error .fdy-combo__button:focus-visible,.fdy-combo--error .fdy-combo__button.is-open,.fdy-combo__button[aria-invalid="true"]:focus-visible{border-color:var(--color-danger);box-shadow:0 0 0 3px color-mix(in srgb,var(--color-danger) 26%,transparent);}
10
15
  .fdy-combo__value{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
@@ -7,6 +7,10 @@
7
7
  .fdy-datepicker__trigger.is-open{border-color:var(--color-primary);box-shadow:0 0 0 3px color-mix(in srgb,var(--color-primary) 26%,transparent);}
8
8
  .fdy-datepicker--error .fdy-datepicker__trigger,.fdy-datepicker__trigger[aria-invalid="true"]{border-color:var(--color-danger);}
9
9
  .fdy-datepicker--error .fdy-datepicker__trigger:focus-visible,.fdy-datepicker--error .fdy-datepicker__trigger.is-open,.fdy-datepicker__trigger[aria-invalid="true"]:focus-visible{border-color:var(--color-danger);box-shadow:0 0 0 3px color-mix(in srgb,var(--color-danger) 26%,transparent);}
10
+ .fdy-datepicker__trigger:disabled,.fdy-datepicker__trigger[aria-disabled="true"]{opacity:.5;cursor:not-allowed;}
11
+ .fdy-datepicker__trigger[aria-readonly="true"]{background:var(--color-surface-2);border-color:var(--color-border);cursor:default;}
12
+ .fdy-datepicker__trigger[aria-readonly="true"]:hover{border-color:var(--color-border);}
13
+ .fdy-datepicker__trigger[aria-readonly="true"]:focus-visible{border-color:var(--color-border-strong);box-shadow:none;}
10
14
  .fdy-datepicker__value{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
11
15
  .fdy-datepicker__value--placeholder{color:var(--color-text-subtle);}
12
16
  .fdy-datepicker__icon{flex:none;display:inline-flex;color:var(--color-text-subtle);}
@@ -4,6 +4,7 @@
4
4
  .fdy-dropzone:hover{border-color:var(--color-text-muted);}
5
5
  .fdy-dropzone:focus-visible{outline:none;border-color:var(--color-primary);box-shadow:0 0 0 3px color-mix(in srgb,var(--color-primary) 26%,transparent);}
6
6
  .fdy-dropzone.is-dragover{border-color:var(--color-primary);background:var(--color-primary-soft);color:var(--color-primary-strong);}
7
+ .fdy-dropzone[aria-disabled="true"],.fdy-dropzone.is-disabled{opacity:.55;cursor:not-allowed;pointer-events:none;}
7
8
  .fdy-dropzone__icon{color:var(--color-text-subtle);}
8
9
  .fdy-dropzone.is-dragover .fdy-dropzone__icon{color:var(--color-primary);}
9
10
  .fdy-dropzone__icon svg{display:block;width:2rem;height:2rem;}
@@ -12,6 +12,12 @@ fieldset.fdy-field>legend{padding:0;float:none;}
12
12
  .fdy-input:hover,.fdy-textarea:hover{border-color:var(--color-text-muted);}
13
13
  .fdy-input:focus,.fdy-textarea:focus{outline:none;border-color:var(--color-primary);box-shadow:inset 0 1px 2px rgba(16,14,30,.04),0 0 0 3px color-mix(in srgb,var(--color-primary) 26%,transparent);}
14
14
  .fdy-input:disabled,.fdy-textarea:disabled,.fdy-input[aria-disabled="true"],.fdy-textarea[aria-disabled="true"]{opacity:.5;cursor:not-allowed;}
15
+ /* Read-only (#39): the value is real and may be read/copied, only not edited. Full contrast (unlike
16
+ :disabled which dims to placeholder-grey), muted surface + softened border so it still reads as
17
+ inert, default cursor, no focus-ring escalation. */
18
+ .fdy-input[readonly],.fdy-textarea[readonly]{background:var(--color-surface-2);border-color:var(--color-border);color:var(--color-text);cursor:default;}
19
+ .fdy-input[readonly]:hover,.fdy-textarea[readonly]:hover{border-color:var(--color-border);}
20
+ .fdy-input[readonly]:focus-visible,.fdy-textarea[readonly]:focus-visible{border-color:var(--color-border-strong);box-shadow:none;}
15
21
  .fdy-input--error,.fdy-input[aria-invalid="true"],.fdy-textarea[aria-invalid="true"]{border-color:var(--color-danger);}
16
22
  .fdy-input--error:focus,.fdy-input[aria-invalid="true"]:focus,.fdy-textarea[aria-invalid="true"]:focus{border-color:var(--color-danger);box-shadow:0 0 0 3px color-mix(in srgb,var(--color-danger) 26%,transparent);}
17
23
  .fdy-help{font-size:var(--text-xs);color:var(--color-text-muted);}
@@ -7,4 +7,10 @@
7
7
  .fdy-rating__star.is-filled svg{fill:var(--color-warning);stroke:var(--color-warning);}
8
8
  .fdy-rating__star input:focus-visible + svg{outline:2px solid var(--color-primary);outline-offset:2px;border-radius:var(--radius-xs);}
9
9
  .fdy-rating--readonly .fdy-rating__star{cursor:default;}
10
+ /* Disabled: not editable and greyed (readonly stays full-strength; disabled dims). */
11
+ .fdy-rating--disabled .fdy-rating__star{cursor:not-allowed;}
12
+ .fdy-rating--disabled .fdy-rating__star svg{opacity:.5;}
13
+ /* Invalid: tint the (outline) empty stars danger so a required-but-empty rating reads as an error,
14
+ paired with a help/error message — never colour alone. */
15
+ .fdy-rating--error .fdy-rating__star svg{stroke:var(--color-danger);}
10
16
  .fdy-rating--sm .fdy-rating__star svg{width:1.1rem;height:1.1rem;}
@@ -2,6 +2,8 @@
2
2
  .fdy-tabs__list{display:flex;gap:var(--space-1);border-bottom:var(--bw) solid var(--color-border);overflow-x:auto;}
3
3
  .fdy-tabs__tab{appearance:none;border:0;background:transparent;cursor:pointer;white-space:nowrap;font-family:var(--font-body);font-size:var(--text-sm);font-weight:var(--weight-medium);color:var(--color-text-muted);padding:var(--space-3) var(--space-4);border-bottom:2px solid transparent;margin-bottom:-1px;transition:color var(--dur-fast) var(--ease-standard),border-color var(--dur-fast) var(--ease-standard);}
4
4
  .fdy-tabs__tab:hover{color:var(--color-text);}
5
+ .fdy-tabs__tab:disabled,.fdy-tabs__tab[aria-disabled="true"]{opacity:.5;cursor:not-allowed;color:var(--color-text-muted);}
6
+ .fdy-tabs__tab:disabled:hover,.fdy-tabs__tab[aria-disabled="true"]:hover{color:var(--color-text-muted);}
5
7
  .fdy-tabs__tab[aria-selected="true"]{color:var(--color-primary-strong);border-bottom-color:var(--color-primary);}
6
8
  .fdy-tabs__tab:focus-visible{outline:none;border-radius:var(--radius-sm);box-shadow:0 0 0 3px color-mix(in srgb,var(--color-primary) 26%,transparent);}
7
9
  .fdy-tabs__panel{padding:var(--space-5) 0;}
@@ -8,6 +8,7 @@
8
8
  .fdy-timepicker__trigger.is-open{border-color:var(--color-primary);box-shadow:0 0 0 3px color-mix(in srgb,var(--color-primary) 26%,transparent);}
9
9
  .fdy-timepicker--error .fdy-timepicker__trigger,.fdy-timepicker__trigger[aria-invalid="true"]{border-color:var(--color-danger);}
10
10
  .fdy-timepicker--error .fdy-timepicker__trigger:focus-visible,.fdy-timepicker--error .fdy-timepicker__trigger.is-open,.fdy-timepicker__trigger[aria-invalid="true"]:focus-visible{border-color:var(--color-danger);box-shadow:0 0 0 3px color-mix(in srgb,var(--color-danger) 26%,transparent);}
11
+ .fdy-timepicker__trigger:disabled,.fdy-timepicker__trigger[aria-disabled="true"]{opacity:.5;cursor:not-allowed;}
11
12
  .fdy-timepicker__value{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-variant-numeric:tabular-nums;}
12
13
  .fdy-timepicker__value--placeholder{color:var(--color-text-subtle);}
13
14
  .fdy-timepicker__icon{flex:none;display:inline-flex;color:var(--color-text-subtle);}
@@ -12,6 +12,8 @@
12
12
  .fdy-tree__icon{flex:none;width:1rem;height:1rem;color:var(--color-text-muted);}
13
13
  .fdy-tree__leaf{padding-left:calc(var(--space-2) * 2 + .9rem);}
14
14
  .fdy-tree__leaf .fdy-tree__icon{color:var(--color-text-subtle);}
15
+ /* Disabled row (branch summary or leaf): dimmed, no hover, not interactive. */
16
+ .fdy-tree__leaf[aria-disabled="true"],.fdy-tree__branch[aria-disabled="true"] > summary{opacity:.5;cursor:not-allowed;pointer-events:none;}
15
17
 
16
18
  /* Checkbox variant — a selectable tree (cascading parent/child via freeday-tree.js).
17
19
  * A native <input class="fdy-checkbox"> sits at the start of every row; a partially
@@ -9,6 +9,8 @@
9
9
  * Markup: <div data-fdy-datetimepicker>
10
10
  * <div data-fdy-datepicker data-value="2026-07-21"></div>
11
11
  * <div data-fdy-timepicker data-value="14:30"></div></div>
12
+ * State: add data-fdy-disabled or data-fdy-invalid on the wrapper to reflect it onto both
13
+ * child triggers as one control (read-only is adapter-only — a vanilla datetime is interactive).
12
14
  */
13
15
  (function () {
14
16
  'use strict';
@@ -22,6 +24,28 @@
22
24
  var tp = wrap.querySelector('[data-fdy-timepicker]');
23
25
  if (!dp || !tp) return;
24
26
 
27
+ // Reflect one wrapper-level state onto BOTH child triggers so a datetime reads as a single
28
+ // disabled/invalid control. Deferred with setTimeout(0) — NOT a microtask: the timepicker
29
+ // enhancer registers its DOMContentLoaded init AFTER this composer, and microtasks drain
30
+ // *between* listeners (so a microtask would run before the timepicker trigger exists). A
31
+ // macrotask waits until the whole init pass is done and both triggers are built. (readonly is
32
+ // intentionally not supported here: a vanilla datetime is interactive; adapters cover read-only.)
33
+ if (wrap.hasAttribute('data-fdy-disabled') || wrap.hasAttribute('data-fdy-invalid')) {
34
+ var applyState = function () {
35
+ var dpTrig = dp.querySelector('.fdy-datepicker__trigger');
36
+ var tpTrig = tp.querySelector('.fdy-timepicker__trigger');
37
+ if (wrap.hasAttribute('data-fdy-disabled')) {
38
+ if (dpTrig) dpTrig.disabled = true;
39
+ if (tpTrig) tpTrig.disabled = true;
40
+ }
41
+ if (wrap.hasAttribute('data-fdy-invalid')) {
42
+ if (dpTrig) dpTrig.setAttribute('aria-invalid', 'true');
43
+ if (tpTrig) tpTrig.setAttribute('aria-invalid', 'true');
44
+ }
45
+ };
46
+ setTimeout(applyState, 0);
47
+ }
48
+
25
49
  var date = dp.getAttribute('data-value') || '';
26
50
  var time = tp.getAttribute('data-value') || '';
27
51