@kubex/zinc 1.1.134 → 1.1.136

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubex/zinc",
3
- "version": "1.1.134",
3
+ "version": "1.1.136",
4
4
  "description": "A collection of web components for building web applications based off of @shoelace-style/Shoelace",
5
5
  "keywords": [
6
6
  "web components",
@@ -137,6 +137,12 @@ export default class ZnDataSelect extends ZincElement implements ZincFormControl
137
137
 
138
138
  @property() conditional = "";
139
139
 
140
+ /**
141
+ * Ids or names of controls that must have a value before this one becomes
142
+ * usable, comma separated. Forwarded to the underlying select.
143
+ */
144
+ @property() requires = "";
145
+
140
146
  protected readonly formControlController = new FormControlController(this);
141
147
 
142
148
  private readonly selectObserver = new MutationController(this, {
@@ -316,6 +322,7 @@ export default class ZnDataSelect extends ZincElement implements ZincFormControl
316
322
  ?disabled="${this.disabled}"
317
323
  ?select-first="${this.selectFirst}"
318
324
  conditional="${this.conditional}"
325
+ requires="${this.requires}"
319
326
  @zn-input="${this.handleInput}"
320
327
  @zn-clear="${this.handleClear}"
321
328
  @blur=${this.blur}
@@ -74,6 +74,12 @@ export default class ZnInlineEdit extends ZincElement implements ZincFormControl
74
74
 
75
75
  @property() conditional = '';
76
76
 
77
+ /**
78
+ * Ids or names of controls that must have a value before this one becomes
79
+ * usable, comma separated.
80
+ */
81
+ @property() requires = '';
82
+
77
83
  @property({ type: Boolean }) disabled: boolean
78
84
 
79
85
  @property({ type: Boolean }) inline: boolean
@@ -356,6 +356,13 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
356
356
 
357
357
  @property() conditional = "";
358
358
 
359
+ /**
360
+ * Ids or names of controls that must have a value before this one becomes
361
+ * usable, comma separated. The control stays disabled until every one of them
362
+ * is filled, and is disabled again if any is cleared.
363
+ */
364
+ @property() requires = "";
365
+
359
366
  /**
360
367
  * The URL to fetch options from. When set, the component fetches JSON from this URL and renders the results as
361
368
  * options. The expected format is an array of objects with `key` and `value` properties:
@@ -691,8 +698,15 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
691
698
  const isExpandIcon = path.some(el => el instanceof Element && el.classList.contains('select__expand-icon'));
692
699
  // if click is inside the prefix area (e.g. checkbox), don't toggle the select
693
700
  const inPrefix = path.some(el => el instanceof Element && el.classList.contains('select__prefix'));
694
- // Ignore disabled controls, fetch loading/error, and clicks on tags (remove buttons)
695
- if (((this.disabled || this._fetchLoading || this._fetchError || isIcon) && !isExpandIcon) || inPrefix) {
701
+ // A control that cannot be used never toggles, whatever was clicked — the
702
+ // expand icon included, or a disabled select still opens from its chevron.
703
+ if (this.disabled || this._fetchLoading || this._fetchError) {
704
+ return;
705
+ }
706
+ // Icon clicks (e.g. a tag's remove button) don't toggle, but the expand
707
+ // icon is what opens the control; the prefix area (e.g. a checkbox) never
708
+ // toggles.
709
+ if ((isIcon && !isExpandIcon) || inPrefix) {
696
710
  return;
697
711
  }
698
712
 
@@ -865,3 +865,106 @@ describe('<zn-select>', () => {
865
865
  expect(popoverOf(second).matches(':popover-open')).to.be.true;
866
866
  });
867
867
  });
868
+
869
+ describe('<zn-select> disabled', () => {
870
+ it('should not open when the expand icon is clicked', async () => {
871
+ const el: any = await fixture(html`
872
+ <zn-select disabled>
873
+ <zn-option value="a">A</zn-option>
874
+ </zn-select>`);
875
+ await el.updateComplete;
876
+
877
+ el.shadowRoot.querySelector('.select__expand-icon')
878
+ .dispatchEvent(new MouseEvent('mousedown', {bubbles: true, composed: true}));
879
+ await el.updateComplete;
880
+
881
+ expect(el.open).to.be.false;
882
+ });
883
+
884
+ it('should open from the expand icon when enabled', async () => {
885
+ const el: any = await fixture(html`
886
+ <zn-select>
887
+ <zn-option value="a">A</zn-option>
888
+ </zn-select>`);
889
+ await el.updateComplete;
890
+
891
+ el.shadowRoot.querySelector('.select__expand-icon')
892
+ .dispatchEvent(new MouseEvent('mousedown', {bubbles: true, composed: true}));
893
+ await el.updateComplete;
894
+
895
+ expect(el.open).to.be.true;
896
+ });
897
+ });
898
+
899
+ describe('<zn-select> requires', () => {
900
+ const pair = () => fixture(html`
901
+ <div>
902
+ <zn-select id="parent" name="parent">
903
+ <zn-option value="p1">P1</zn-option>
904
+ </zn-select>
905
+ <zn-select id="child" name="child" requires="parent" disabled>
906
+ <zn-option value="c1">C1</zn-option>
907
+ </zn-select>
908
+ </div>`);
909
+
910
+ it('should stay disabled while the required control is empty', async () => {
911
+ const root: any = await pair();
912
+ const child = root.querySelector('#child');
913
+ await child.updateComplete;
914
+
915
+ expect(child.disabled).to.be.true;
916
+ });
917
+
918
+ it('should enable once the required control has a value', async () => {
919
+ const root: any = await pair();
920
+ const parent = root.querySelector('#parent');
921
+ const child = root.querySelector('#child');
922
+ await child.updateComplete;
923
+
924
+ parent.value = 'p1';
925
+ parent.dispatchEvent(new CustomEvent('zn-change', {bubbles: true, composed: true}));
926
+ await child.updateComplete;
927
+
928
+ expect(child.disabled).to.be.false;
929
+ });
930
+
931
+ it('should disable again when the required control is cleared', async () => {
932
+ const root: any = await pair();
933
+ const parent = root.querySelector('#parent');
934
+ const child = root.querySelector('#child');
935
+ await child.updateComplete;
936
+
937
+ parent.value = 'p1';
938
+ parent.dispatchEvent(new CustomEvent('zn-change', {bubbles: true, composed: true}));
939
+ await child.updateComplete;
940
+
941
+ parent.value = '';
942
+ parent.dispatchEvent(new CustomEvent('zn-change', {bubbles: true, composed: true}));
943
+ await child.updateComplete;
944
+
945
+ expect(child.disabled).to.be.true;
946
+ });
947
+
948
+ it('should require every named control', async () => {
949
+ const root: any = await fixture(html`
950
+ <div>
951
+ <zn-select id="a" name="a"><zn-option value="1">1</zn-option></zn-select>
952
+ <zn-select id="b" name="b"><zn-option value="2">2</zn-option></zn-select>
953
+ <zn-select id="c" name="c" requires="a,b" disabled></zn-select>
954
+ </div>`);
955
+ const a = root.querySelector('#a');
956
+ const b = root.querySelector('#b');
957
+ const c = root.querySelector('#c');
958
+ await c.updateComplete;
959
+
960
+ a.value = '1';
961
+ a.dispatchEvent(new CustomEvent('zn-change', {bubbles: true, composed: true}));
962
+ await c.updateComplete;
963
+ expect(c.disabled, 'one of two filled').to.be.true;
964
+
965
+ b.value = '2';
966
+ b.dispatchEvent(new CustomEvent('zn-change', {bubbles: true, composed: true}));
967
+ await c.updateComplete;
968
+ expect(c.disabled, 'both filled').to.be.false;
969
+ });
970
+ });
@@ -3,13 +3,26 @@ import type {ReactiveController, ReactiveControllerHost} from 'lit';
3
3
 
4
4
  export interface ConditionalHost {
5
5
  conditional: string;
6
+ requires: string;
6
7
  disabled: boolean;
7
8
  }
8
9
 
9
- /** A reactive controller that disables the host when linked selects have a value. */
10
+ type ValuedElement = Element & { value: string | string[] };
11
+
12
+ function hasValue(el: ValuedElement): boolean {
13
+ const values = Array.isArray(el.value) ? el.value : [el.value];
14
+ return values.some(v => v !== '' && v !== '0');
15
+ }
16
+
17
+ /**
18
+ * A reactive controller that drives the host's disabled state from other form
19
+ * controls: `conditional` disables the host once a linked control has a value,
20
+ * `requires` keeps it disabled until every named control has one.
21
+ */
10
22
  export class ConditionalController implements ReactiveController {
11
23
  host: ReactiveControllerHost & Element & ConditionalHost;
12
- private conditionals: (Element & { value: string | string[] })[] = [];
24
+ private conditionals: ValuedElement[] = [];
25
+ private required: ValuedElement[] = [];
13
26
  private initiallyDisabled = false;
14
27
  private readonly handleChange = () => this.checkConditionals();
15
28
 
@@ -19,43 +32,48 @@ export class ConditionalController implements ReactiveController {
19
32
 
20
33
  /** Call from the host's `firstUpdated()` to parse IDs, find elements, attach listeners, and run the initial check. */
21
34
  setup() {
22
- if (this.host.conditional === '') {
35
+ this.conditionals = this.resolve(this.host.conditional);
36
+ this.required = this.resolve(this.host.requires);
37
+
38
+ this.initiallyDisabled = this.host.disabled;
39
+
40
+ const watched = [...this.conditionals, ...this.required];
41
+ if (watched.length === 0) {
23
42
  return;
24
43
  }
25
44
 
26
- const ids = this.host.conditional.split(',').map(id => id.trim());
45
+ watched.forEach(el => {
46
+ el.addEventListener('zn-change', this.handleChange);
47
+ el.addEventListener('zn-input', this.handleChange);
48
+ });
49
+
50
+ // trigger the check once to initialize
51
+ this.checkConditionals();
52
+ }
53
+
54
+ /** Resolve a comma-separated list of ids or names to the controls they name. */
55
+ private resolve(list: string): ValuedElement[] {
56
+ if (!list) {
57
+ return [];
58
+ }
27
59
 
28
- ids.forEach(id => {
29
- const byId = deepQuerySelectorAll(idSelector(id), document.documentElement, '') as (Element & {
30
- value: string | string[];
31
- })[];
32
- const byName = deepQuerySelectorAll(`[name="${id}"]`, document.documentElement, '') as (Element & {
33
- value: string | string[];
34
- })[];
60
+ const found: ValuedElement[] = [];
61
+ const seen = new Set<Element>();
62
+
63
+ list.split(',').map(id => id.trim()).filter(Boolean).forEach(id => {
64
+ const byId = deepQuerySelectorAll(idSelector(id), document.documentElement, '') as ValuedElement[];
65
+ const byName = deepQuerySelectorAll(`[name="${id}"]`, document.documentElement, '') as ValuedElement[];
35
66
 
36
67
  // de-duplicate in case the same element matches both id and name
37
- const seen = new Set<Element>();
38
68
  [...byId, ...byName].forEach(el => {
39
69
  if (!seen.has(el)) {
40
70
  seen.add(el);
41
- this.conditionals.push(el);
71
+ found.push(el);
42
72
  }
43
73
  });
44
74
  });
45
75
 
46
- this.initiallyDisabled = this.host.disabled;
47
-
48
- if (ids.length === 0) {
49
- return;
50
- }
51
-
52
- this.conditionals.forEach(select => {
53
- select.addEventListener('zn-change', this.handleChange);
54
- select.addEventListener('zn-input', this.handleChange);
55
- });
56
-
57
- // trigger the check once to initialize
58
- this.checkConditionals();
76
+ return found;
59
77
  }
60
78
 
61
79
  hostConnected() {
@@ -63,12 +81,13 @@ export class ConditionalController implements ReactiveController {
63
81
  }
64
82
 
65
83
  hostDisconnected() {
66
- this.conditionals.forEach(select => {
67
- select.removeEventListener('zn-change', this.handleChange);
68
- // select.removeEventListener('zn-input', this.handleChange);
84
+ [...this.conditionals, ...this.required].forEach(el => {
85
+ el.removeEventListener('zn-change', this.handleChange);
86
+ el.removeEventListener('zn-input', this.handleChange);
69
87
  });
70
88
 
71
89
  this.conditionals = [];
90
+ this.required = [];
72
91
  }
73
92
 
74
93
  /** Re-evaluate the conditional state. Call from the host when needed. */
@@ -77,12 +96,11 @@ export class ConditionalController implements ReactiveController {
77
96
  }
78
97
 
79
98
  private checkConditionals() {
80
- const shouldDisable = this.conditionals.some(select => {
81
- let linkedValues = Array.isArray(select.value) ? select.value : [select.value];
82
- linkedValues = linkedValues.filter(v => (v !== '' && v !== "0"));
83
- return linkedValues.length > 0;
84
- });
99
+ const blocked = this.conditionals.some(hasValue);
100
+ const unmet = this.required.length > 0 && !this.required.every(hasValue);
101
+
102
+ const latched = this.required.length > 0 ? false : this.initiallyDisabled;
85
103
 
86
- this.host.disabled = this.initiallyDisabled || shouldDisable;
104
+ this.host.disabled = latched || blocked || unmet;
87
105
  }
88
106
  }